add: gui and tsdiag

This commit is contained in:
iceBear67
2026-07-26 09:39:17 +00:00
parent 5dc1759d80
commit 5b3a7e147c
41 changed files with 16451 additions and 8 deletions
+677
View File
@@ -0,0 +1,677 @@
package gui
import (
"context"
"image"
"io"
"log/slog"
"strings"
"sync/atomic"
"time"
"gioui.org/app"
"gioui.org/font"
"gioui.org/io/clipboard"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"tslink/core"
)
// Options configures the GUI.
type Options struct {
Version string
ConfigPath string
ConfigURL string
Supervisor *core.Supervisor
Logs *core.LogBuffer
Logger *slog.Logger
// IPInfoToken is passed through to the diagnostics runner.
IPInfoToken string
// StartDark selects the initial theme.
StartDark bool
}
// pageID identifies a top-level view.
type pageID int
const (
pageOverview pageID = iota
pagePeers
pageLan
pageDiag
pageLogs
pageSettings
)
type navEntry struct {
id pageID
label Key
icon IconFunc
click widget.Clickable
}
// App is the whole GUI. It owns the window event loop and holds every page's
// state.
type App struct {
opt Options
logger *slog.Logger
th *Theme
fonts *FontSet
win *app.Window
nav []navEntry
current pageID
overview *overviewPage
peers *peersPage
lan *lanPage
diag *diagPage
logs *logsPage
settings *settingsPage
splash *splashView
overlay *logOverlay
overlayBtn widget.Clickable
themeBtn widget.Clickable
toastMsg string
toastLevel StatusLevel
toastUntil time.Time
// fontUpgrade carries CJK faces parsed off the UI goroutine.
fontUpgrade chan []font.FontFace
// needsTick is set during layout when the current frame shows something
// that changes with wall-clock time — relative timestamps, uptime, a
// running step's elapsed counter. When it is false the periodic refresh is
// skipped and the window stops repainting altogether.
//
// This is not micro-optimisation: a full repaint costs tens of
// milliseconds under software rendering (Gio stencils every rounded
// rectangle and icon as a path), so a once-a-second refresh of a screen
// with nothing time-dependent on it is pure waste.
needsTick atomic.Bool
}
// New builds the application.
func New(opt Options) *App {
logger := opt.Logger
if logger == nil {
logger = slog.Default()
}
fonts := LoadFonts()
th := NewTheme(fonts, opt.StartDark)
a := &App{
opt: opt,
logger: logger.With("from", "gui"),
th: th,
fonts: fonts,
current: pageOverview,
fontUpgrade: make(chan []font.FontFace, 1),
}
a.nav = []navEntry{
{id: pageOverview, label: KNavOverview, icon: IconGrid},
{id: pagePeers, label: KNavPeers, icon: IconNodes},
{id: pageLan, label: KNavLan, icon: IconBroadcast},
{id: pageDiag, label: KNavDiag, icon: IconPulse},
{id: pageLogs, label: KNavLogs, icon: IconList},
{id: pageSettings, label: KNavSettings, icon: IconSliders},
}
a.overview = newOverviewPage()
a.peers = newPeersPage()
a.lan = newLanPage()
a.diag = newDiagPage(a)
a.logs = newLogsPage(a)
a.settings = newSettingsPage(a)
a.splash = newSplashView()
a.overlay = newLogOverlay()
return a
}
// Run opens the window and drives the event loop. It returns when the window
// closes.
func (a *App) Run(ctx context.Context) error {
w := new(app.Window)
w.Option(
app.Title("tslink"),
app.Size(unit.Dp(1120), unit.Dp(740)),
app.MinSize(unit.Dp(880), unit.Dp(560)),
)
a.win = w
go a.watch(ctx, w)
go a.upgradeFonts()
var ops op.Ops
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
a.applyFontUpgrade()
a.layout(gtx)
e.Frame(gtx.Ops)
}
}
}
// upgradeFonts parses the system CJK font off the UI goroutine. The splash
// screen exists partly to cover this: a 20 MB font collection takes long
// enough to parse that doing it inline would stall the first frame.
func (a *App) upgradeFonts() {
if !a.fonts.HasCJK || a.fonts.CJKPath == "" {
return
}
faces, err := LoadCJKFaces(a.fonts.CJKPath, a.logger)
if err != nil {
a.logger.Warn("failed to load cjk font, relying on system fallback",
"path", a.fonts.CJKPath, "err", err)
return
}
if len(faces) == 0 {
return
}
select {
case a.fontUpgrade <- faces:
if a.win != nil {
a.win.Invalidate()
}
default:
}
}
func (a *App) applyFontUpgrade() {
select {
case faces := <-a.fontUpgrade:
merged := append(append([]font.FontFace(nil), a.fonts.Collection...), faces...)
a.fonts.Collection = merged
a.th.Shaper = text.NewShaper(text.WithCollection(merged))
a.logger.Debug("shaper upgraded with cjk faces", "faces", len(faces))
default:
}
}
// watch coalesces change notifications from every data source into window
// invalidations, capped so a burst of log lines cannot drive the render loop.
func (a *App) watch(ctx context.Context, w *app.Window) {
var chans []<-chan struct{}
var cancels []func()
defer func() {
for _, c := range cancels {
c()
}
}()
if a.opt.Supervisor != nil {
ch, cancel := a.opt.Supervisor.Subscribe()
chans = append(chans, ch)
cancels = append(cancels, cancel)
}
if a.opt.Logs != nil {
ch, cancel := a.opt.Logs.Subscribe()
chans = append(chans, ch)
cancels = append(cancels, cancel)
}
// A ticker keeps relative timestamps ("3m ago") and the live latency
// column honest even when nothing else changed.
tick := time.NewTicker(time.Second)
defer tick.Stop()
dirty := false
throttle := time.NewTicker(70 * time.Millisecond)
defer throttle.Stop()
agg := make(chan struct{}, 1)
for _, ch := range chans {
go func(ch <-chan struct{}) {
for {
select {
case <-ctx.Done():
return
case _, ok := <-ch:
if !ok {
return
}
select {
case agg <- struct{}{}:
default:
}
}
}
}(ch)
}
for {
select {
case <-ctx.Done():
return
case <-agg:
dirty = true
case <-tick.C:
if a.needsTick.Load() {
dirty = true
}
case <-throttle.C:
if dirty {
dirty = false
w.Invalidate()
}
}
}
}
// state returns the current supervisor snapshot, or a zero value.
func (a *App) state() core.State {
if a.opt.Supervisor == nil {
return core.State{}
}
return a.opt.Supervisor.Snapshot()
}
// ---------------------------------------------------------------------------
// Clipboard + toast
// ---------------------------------------------------------------------------
// copyToClipboard puts s on the system clipboard and shows a confirmation.
func (a *App) copyToClipboard(gtx C, s string, msg string) {
gtx.Execute(clipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(s)),
})
if msg == "" {
msg = a.th.T(KCopied)
}
a.notify(msg, LevelOK)
}
// notify shows a transient message at the bottom of the window.
func (a *App) notify(msg string, level StatusLevel) {
a.toastMsg = msg
a.toastLevel = level
a.toastUntil = time.Now().Add(3200 * time.Millisecond)
if a.win != nil {
a.win.Invalidate()
}
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
func (a *App) layout(gtx C) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
st := a.state()
// A terminal error screen has nothing that ages; everything else does
// (uptime, "last seen", a running step's timer).
a.needsTick.Store(st.Phase != core.PhaseError && st.Phase != core.PhaseStopped)
// Handle nav clicks before drawing so the click lands on this frame.
for i := range a.nav {
if a.nav[i].click.Clicked(gtx) {
a.current = a.nav[i].id
}
}
if a.overlayBtn.Clicked(gtx) {
a.overlay.visible = !a.overlay.visible
}
if a.themeBtn.Clicked(gtx) {
th.SetDark(!th.Dark)
}
return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
if !st.Ready() {
// The splash owns the whole window until the service is up.
return a.splash.Layout(a, gtx, st)
}
return a.shell(gtx, st)
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
return a.overlay.Layout(a, gtx, !st.Ready())
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
return a.layoutToast(gtx)
}),
)
}
// shell draws the sidebar plus the active page.
func (a *App) shell(gtx C, st core.State) D {
compact := gtx.Constraints.Max.X < gtx.Dp(1000)
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return a.sidebar(gtx, compact)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return a.header(gtx, st) }),
layout.Flexed(1, func(gtx C) D {
return layout.Inset{
Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceLG,
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.page(gtx, st)
})
}),
)
}),
)
}
func (a *App) page(gtx C, st core.State) D {
switch a.current {
case pagePeers:
return a.peers.Layout(a, gtx, st)
case pageLan:
return a.lan.Layout(a, gtx, st)
case pageDiag:
return a.diag.Layout(a, gtx, st)
case pageLogs:
return a.logs.Layout(a, gtx, st)
case pageSettings:
return a.settings.Layout(a, gtx, st)
default:
return a.overview.Layout(a, gtx, st)
}
}
func (a *App) sidebar(gtx C, compact bool) D {
th := a.th
w := gtx.Dp(212)
if compact {
w = gtx.Dp(64)
}
gtx.Constraints.Min.X = w
gtx.Constraints.Max.X = w
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
size := image.Pt(w, gtx.Constraints.Max.Y)
paint.FillShape(gtx.Ops, th.P.BgElevated, clip.Rect{Max: size}.Op())
// Hairline separating rail from content.
paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{
Min: image.Pt(size.X-1, 0), Max: size,
}.Op())
return D{Size: size}
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min.X = w
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return a.brand(gtx, compact) }),
layout.Rigid(func(gtx C) D {
children := make([]layout.FlexChild, 0, len(a.nav))
for i := range a.nav {
children = append(children, layout.Rigid(func(gtx C) D {
return a.navItem(gtx, &a.nav[i], compact)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}),
)
}),
)
}
func (a *App) brand(gtx C, compact bool) D {
th := a.th
return layout.Inset{
Top: SpaceXL, Bottom: SpaceLG, Left: SpaceLG, Right: SpaceLG,
}.Layout(gtx, func(gtx C) D {
if compact {
return layout.Center.Layout(gtx, func(gtx C) D {
return IconBroadcast(gtx, gtx.Dp(22), th.P.Accent)
})
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconBroadcast(gtx, gtx.Dp(20), th.P.Accent)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
l := th.Text(SizeSubtitle, th.P.TextPri, "tslink")
l.Font.Weight = font.Bold
return l.Layout(gtx)
}),
layout.Rigid(OneLine(th.Caption(th.T(KAppSubtitle))).Layout),
)
}),
)
})
}
func (a *App) navItem(gtx C, n *navEntry, compact bool) D {
th := a.th
selected := a.current == n.id
fg := th.P.TextSec
if selected {
fg = th.P.TextPri
} else if n.click.Hovered() {
fg = th.P.TextPri
}
return n.click.Layout(gtx, func(gtx C) D {
return layout.Inset{Left: SpaceSM, Right: SpaceSM, Top: 2, Bottom: 2}.Layout(gtx, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
size := gtx.Constraints.Min
switch {
case selected:
FillRRect(gtx, size, RadiusSM, WithAlpha(th.P.Accent, 0.16))
paint.FillShape(gtx.Ops, th.P.Accent, clip.UniformRRect(
image.Rect(0, size.Y/2-gtx.Dp(8), gtx.Dp(3), size.Y/2+gtx.Dp(8)),
gtx.Dp(2)).Op(gtx.Ops))
case n.click.Hovered():
FillRRect(gtx, size, RadiusSM, th.P.SurfaceHi)
}
return D{Size: size}
}),
layout.Stacked(func(gtx C) D {
pad := layout.Inset{Top: 9, Bottom: 9, Left: SpaceMD, Right: SpaceMD}
if compact {
pad = layout.Inset{Top: 10, Bottom: 10}
}
return pad.Layout(gtx, func(gtx C) D {
if compact {
return layout.Center.Layout(gtx, func(gtx C) D {
return n.icon(gtx, gtx.Dp(19), fg)
})
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return n.icon(gtx, gtx.Dp(17), fg)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
l := th.Text(SizeBody, fg, th.T(n.label))
if selected {
l.Font.Weight = font.Medium
}
return l.Layout(gtx)
}),
)
})
}),
)
})
})
}
func (a *App) header(gtx C, st core.State) D {
th := a.th
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
size := gtx.Constraints.Min
paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{
Min: image.Pt(0, size.Y-1), Max: size,
}.Op())
return D{Size: size}
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return th.Title(a.pageTitle()).Layout(gtx)
}),
layout.Rigid(func(gtx C) D { return a.statusPill(gtx, st) }),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
icon := IconList
level := LevelNeutral
if a.overlay.visible {
level = LevelInfo
}
return th.IconButton(gtx, &a.overlayBtn, icon, level)
}),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, &a.themeBtn, IconGlobe, LevelNeutral)
}),
)
})
}),
)
}
func (a *App) pageTitle() string {
th := a.th
for _, n := range a.nav {
if n.id == a.current {
return th.T(n.label)
}
}
return "tslink"
}
func (a *App) statusPill(gtx C, st core.State) D {
th := a.th
var (
label string
level StatusLevel
pulse bool
)
switch st.Phase {
case core.PhaseReady:
// Steady state: no animation. See [Theme.StatusDot].
label, level = th.T(KStateRunning), LevelOK
case core.PhaseStarting:
label, level = th.T(KStateConnecting), LevelInfo
pulse = true
case core.PhaseRetrying:
label, level = th.T(KStateRetrying), LevelWarn
pulse = true
case core.PhaseError:
label, level = th.T(KStateError), LevelFail
case core.PhaseStopped:
label, level = th.T(KStateStopped), LevelNeutral
default:
label, level = th.T(KStateStarting), LevelNeutral
}
fg := th.StatusColor(level)
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
FillRRect(gtx, gtx.Constraints.Min, RadiusPill, WithAlpha(fg, 0.13))
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
return layout.Inset{Top: 5, Bottom: 5, Left: SpaceMD, Right: SpaceMD}.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, pulse)
}),
HGap(SpaceSM),
layout.Rigid(th.Text(SizeCaption, fg, label).Layout),
)
})
}),
)
}
func (a *App) layoutToast(gtx C) D {
if a.toastMsg == "" || time.Now().After(a.toastUntil) {
return D{}
}
th := a.th
// Keep repainting until the toast expires.
gtx.Execute(op.InvalidateCmd{At: a.toastUntil})
return layout.S.Layout(gtx, func(gtx C) D {
return layout.Inset{Bottom: Space2XL}.Layout(gtx, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.SurfaceHi)
StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border)
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
return layout.Inset{
Top: SpaceSM, Bottom: SpaceSM, Left: SpaceLG, Right: SpaceLG,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, a.toastLevel, false)
}),
HGap(SpaceSM),
layout.Rigid(th.Text(SizeBody, th.P.TextPri, a.toastMsg).Layout),
)
})
}),
)
})
})
}
// ---------------------------------------------------------------------------
// Section heading used by pages
// ---------------------------------------------------------------------------
// sectionTitle renders a page-level heading with an optional trailing widget.
func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget) D {
th := a.th
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, 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 {
l := th.Text(SizeSubtitle, th.P.TextPri, title)
l.Font.Weight = font.SemiBold
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if subtitle == "" {
return D{}
}
return th.Caption(subtitle).Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx C) D {
if trailing == nil {
return D{}
}
return trailing(gtx)
}),
)
})
}
+515
View File
@@ -0,0 +1,515 @@
package gui
import (
"image"
"image/color"
"math"
"time"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
)
// ChartPoint is one sample. A point with OK false is a failed probe: the line
// breaks there rather than being interpolated across, because pretending a
// dropped ping was a slow one hides exactly the problem the user opened this
// panel to find.
type ChartPoint struct {
At time.Time
Value float64 // milliseconds
OK bool
}
// ChartSeries is one line on the chart.
type ChartSeries struct {
Name string
Color color.NRGBA
Points []ChartPoint
Hidden bool
// Subtitle appears under the name in the legend, typically the peer's route.
Subtitle string
}
// ChartStyle configures the plot.
type ChartStyle struct {
Height unit.Dp
// Window is how far back the x axis reaches.
Window time.Duration
// Now anchors the right edge.
Now time.Time
// Unit labels the y axis.
Unit string
// FillSingle draws a soft gradient under the line when exactly one series
// is visible, which reads better than a lone stroke on a big canvas.
FillSingle bool
}
// Chart is the stateful part of the plot: which point the pointer is near.
type Chart struct {
hover f32.Point
hovering bool
// plot is the last plotted rectangle, used to map hover x back to a time.
plot image.Rectangle
}
// HoverIndex returns the sample index the pointer is nearest within s, or -1.
func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 {
return -1
}
frac := float64(c.hover.X-float32(c.plot.Min.X)) / float64(c.plot.Dx())
if frac < 0 || frac > 1 {
return -1
}
target := st.Now.Add(-st.Window).Add(time.Duration(frac * float64(st.Window)))
best, bestDelta := -1, time.Duration(math.MaxInt64)
for i, p := range series.Points {
d := p.At.Sub(target)
if d < 0 {
d = -d
}
if d < bestDelta {
best, bestDelta = i, d
}
}
// Only report a match when the nearest sample is genuinely close, so the
// crosshair does not snap to a distant point in a sparse series.
if bestDelta > st.Window/20 {
return -1
}
return best
}
// Layout draws the chart.
func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D {
if st.Window <= 0 {
st.Window = 20 * time.Minute
}
if st.Now.IsZero() {
st.Now = gtx.Now
}
h := gtx.Dp(st.Height)
if h <= 0 {
h = gtx.Dp(180)
}
w := gtx.Constraints.Max.X
size := image.Pt(w, h)
gutterL := gtx.Dp(44)
gutterB := gtx.Dp(18)
plot := image.Rect(gutterL, gtx.Dp(6), w-gtx.Dp(6), h-gutterB)
c.plot = plot
if plot.Dx() <= 0 || plot.Dy() <= 0 {
return D{Size: size}
}
// Pointer tracking over the plot area.
c.update(gtx, size)
yMax := niceMax(maxVisible(series))
tMin := st.Now.Add(-st.Window)
c.drawGrid(t, gtx, plot, yMax, st)
for _, s := range series {
if s.Hidden || len(s.Points) == 0 {
continue
}
c.drawSeries(t, gtx, plot, s, tMin, st.Now, yMax, st.FillSingle && visibleCount(series) == 1)
}
c.drawCrosshair(t, gtx, plot, series, st, tMin, yMax)
return D{Size: size}
}
func (c *Chart) update(gtx C, size image.Point) {
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
event.Op(gtx.Ops, c)
for {
ev, ok := gtx.Event(pointer.Filter{
Target: c,
Kinds: pointer.Move | pointer.Enter | pointer.Leave | pointer.Drag,
})
if !ok {
break
}
pe, ok := ev.(pointer.Event)
if !ok {
continue
}
switch pe.Kind {
case pointer.Leave, pointer.Cancel:
c.hovering = false
default:
c.hovering = true
c.hover = pe.Position
}
}
}
func maxVisible(series []ChartSeries) float64 {
m := 0.0
for _, s := range series {
if s.Hidden {
continue
}
for _, p := range s.Points {
if p.OK && p.Value > m {
m = p.Value
}
}
}
return m
}
func visibleCount(series []ChartSeries) int {
n := 0
for _, s := range series {
if !s.Hidden && len(s.Points) > 0 {
n++
}
}
return n
}
// niceMax rounds an axis maximum up to a 1/2/5 x 10^n step so the gridlines
// land on numbers a human reads without effort.
func niceMax(v float64) float64 {
if v <= 0 {
return 50
}
v *= 1.15 // headroom so the peak is not glued to the top edge
exp := math.Floor(math.Log10(v))
base := math.Pow(10, exp)
switch f := v / base; {
case f <= 1:
return base
case f <= 2:
return 2 * base
case f <= 5:
return 5 * base
default:
return 10 * base
}
}
func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st ChartStyle) {
const rows = 4
lineCol := WithAlpha(t.P.Border, 0.9)
for i := 0; i <= rows; i++ {
frac := float64(i) / rows
y := plot.Max.Y - int(frac*float64(plot.Dy()))
paint.FillShape(gtx.Ops, lineCol, clip.Rect{
Min: image.Pt(plot.Min.X, y),
Max: image.Pt(plot.Max.X, y+1),
}.Op())
val := frac * yMax
lbl := t.MonoLabel(SizeCaption, t.P.TextDim, trimZero(val, 0))
lbl.Alignment = text.End
off := op.Offset(image.Pt(0, y-gtx.Dp(7))).Push(gtx.Ops)
lgtx := gtx
lgtx.Constraints.Max.X = plot.Min.X - gtx.Dp(6)
lgtx.Constraints.Min.X = lgtx.Constraints.Max.X
lbl.Layout(lgtx)
off.Pop()
}
// X axis: three labels, oldest to newest.
labels := []struct {
frac float64
txt string
}{
{0, "-" + FormatDuration(st.Window)},
{0.5, "-" + FormatDuration(st.Window/2)},
{1, "now"},
}
if t.Lang == LangZH {
labels[2].txt = "现在"
}
for _, l := range labels {
x := plot.Min.X + int(l.frac*float64(plot.Dx()))
lbl := t.Text(SizeCaption, t.P.TextDim, l.txt)
switch {
case l.frac == 0:
lbl.Alignment = text.Start
case l.frac == 1:
lbl.Alignment = text.End
default:
lbl.Alignment = text.Middle
}
wide := gtx.Dp(70)
ox := x - wide/2
if l.frac == 0 {
ox = x
}
if l.frac == 1 {
ox = x - wide
}
off := op.Offset(image.Pt(ox, plot.Max.Y+gtx.Dp(3))).Push(gtx.Ops)
lgtx := gtx
lgtx.Constraints.Max.X = wide
lgtx.Constraints.Min.X = wide
lbl.Layout(lgtx)
off.Pop()
}
}
// pos maps a sample onto plot coordinates.
func pos(plot image.Rectangle, tMin, tMax time.Time, yMax float64, p ChartPoint) f32.Point {
span := tMax.Sub(tMin)
if span <= 0 {
span = time.Second
}
fx := float64(p.At.Sub(tMin)) / float64(span)
fx = math.Max(0, math.Min(1, fx))
fy := p.Value / yMax
fy = math.Max(0, math.Min(1, fy))
return f32.Pt(
float32(plot.Min.X)+float32(fx)*float32(plot.Dx()),
float32(plot.Max.Y)-float32(fy)*float32(plot.Dy()),
)
}
func (c *Chart) drawSeries(t *Theme, gtx C, plot image.Rectangle, s ChartSeries, tMin, tMax time.Time, yMax float64, fill bool) {
defer clip.Rect(plot).Push(gtx.Ops).Pop()
// Optional area fill, drawn first so the stroke sits on top.
if fill {
var ap clip.Path
ap.Begin(gtx.Ops)
started := false
var lastX float32
for _, p := range s.Points {
if !p.OK {
continue
}
pt := pos(plot, tMin, tMax, yMax, p)
if !started {
ap.MoveTo(f32.Pt(pt.X, float32(plot.Max.Y)))
ap.LineTo(pt)
started = true
} else {
ap.LineTo(pt)
}
lastX = pt.X
}
if started {
ap.LineTo(f32.Pt(lastX, float32(plot.Max.Y)))
ap.Close()
paint.FillShape(gtx.Ops, WithAlpha(s.Color, 0.13), clip.Outline{Path: ap.End()}.Op())
}
}
var p clip.Path
p.Begin(gtx.Ops)
pen := false
for _, sp := range s.Points {
if !sp.OK {
pen = false // break the line across a dropped probe
continue
}
pt := pos(plot, tMin, tMax, yMax, sp)
if !pen {
p.MoveTo(pt)
pen = true
} else {
p.LineTo(pt)
}
}
paint.FillShape(gtx.Ops, s.Color,
clip.Stroke{Path: p.End(), Width: float32(gtx.Dp(1.6))}.Op())
// Mark failures with a small tick on the baseline so loss is visible even
// when the surrounding samples are fine.
for _, sp := range s.Points {
if sp.OK {
continue
}
pt := pos(plot, tMin, tMax, yMax, ChartPoint{At: sp.At, Value: 0, OK: true})
x := int(pt.X)
paint.FillShape(gtx.Ops, WithAlpha(t.P.Fail, 0.75), clip.Rect{
Min: image.Pt(x, plot.Max.Y-gtx.Dp(5)),
Max: image.Pt(x+max(gtx.Dp(1.5), 1), plot.Max.Y),
}.Op())
}
// A dot on the most recent successful sample anchors the eye to "now".
for i := len(s.Points) - 1; i >= 0; i-- {
if !s.Points[i].OK {
continue
}
pt := pos(plot, tMin, tMax, yMax, s.Points[i])
d := gtx.Dp(5)
off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops)
Circle(gtx, d, s.Color)
off.Pop()
break
}
}
func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, st ChartStyle, tMin time.Time, yMax float64) {
if !c.hovering {
return
}
x := int(c.hover.X)
if x < plot.Min.X || x > plot.Max.X {
return
}
paint.FillShape(gtx.Ops, WithAlpha(t.P.TextDim, 0.5), clip.Rect{
Min: image.Pt(x, plot.Min.Y),
Max: image.Pt(x+1, plot.Max.Y),
}.Op())
for _, s := range series {
if s.Hidden {
continue
}
i := c.HoverIndex(s, st)
if i < 0 || !s.Points[i].OK {
continue
}
pt := pos(plot, tMin, st.Now, yMax, s.Points[i])
d := gtx.Dp(7)
off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops)
Circle(gtx, d, s.Color)
inner := gtx.Dp(3)
off2 := op.Offset(image.Pt((d-inner)/2, (d-inner)/2)).Push(gtx.Ops)
Circle(gtx, inner, t.P.Bg)
off2.Pop()
off.Pop()
}
}
// ---------------------------------------------------------------------------
// Legend
// ---------------------------------------------------------------------------
// LegendEntry is one row of the chart legend.
type LegendEntry struct {
Name string
Subtitle string
Color color.NRGBA
Value string
Hidden bool
}
// Legend renders the chart legend as a wrapping row of toggles. The caller
// supplies a clickable per entry so hiding a noisy peer is one click away.
func (t *Theme) Legend(gtx C, entries []LegendEntry, click func(i int) layout.Widget) D {
if len(entries) == 0 {
return D{}
}
children := make([]layout.FlexChild, 0, len(entries))
for i := range entries {
children = append(children, layout.Rigid(click(i)))
}
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...)
}
// LegendChip draws one legend entry.
func (t *Theme) LegendChip(gtx C, e LegendEntry, hovered bool) D {
fg := t.P.TextSec
swatch := e.Color
if e.Hidden {
fg = WithAlpha(t.P.TextDim, 0.7)
swatch = WithAlpha(e.Color, 0.3)
}
if hovered {
fg = t.P.TextPri
}
return layout.Inset{Right: SpaceMD, Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D {
h := gtx.Dp(3)
w := gtx.Dp(12)
FillRRect(gtx, image.Pt(w, h), RadiusPill, swatch)
return D{Size: image.Pt(w, h)}
})
}),
layout.Rigid(OneLine(t.Text(SizeCaption, fg, e.Name)).Layout),
layout.Rigid(func(gtx C) D {
if e.Value == "" {
return D{}
}
return layout.Inset{Left: 5}.Layout(gtx,
t.MonoLabel(SizeCaption, WithAlpha(fg, 0.8), e.Value).Layout)
}),
)
})
}
// ---------------------------------------------------------------------------
// Sparkline
// ---------------------------------------------------------------------------
// Sparkline draws a compact latency trace for a table row: no axes, no labels,
// just the shape of the last few minutes.
func (t *Theme) Sparkline(gtx C, points []ChartPoint, col color.NRGBA, w, h unit.Dp) D {
width, height := gtx.Dp(w), gtx.Dp(h)
size := image.Pt(width, height)
if len(points) < 2 || width <= 0 || height <= 0 {
// A flat hairline is a clearer "no data yet" than empty space.
paint.FillShape(gtx.Ops, WithAlpha(t.P.Border, 0.8), clip.Rect{
Min: image.Pt(0, height/2),
Max: image.Pt(width, height/2+1),
}.Op())
return D{Size: size}
}
yMax := 0.0
for _, p := range points {
if p.OK && p.Value > yMax {
yMax = p.Value
}
}
if yMax <= 0 {
yMax = 1
}
yMax *= 1.2
plot := image.Rect(0, 1, width, height-1)
tMin, tMax := points[0].At, points[len(points)-1].At
if !tMax.After(tMin) {
tMax = tMin.Add(time.Second)
}
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
var p clip.Path
p.Begin(gtx.Ops)
pen := false
for _, sp := range points {
if !sp.OK {
pen = false
continue
}
pt := pos(plot, tMin, tMax, yMax, sp)
if !pen {
p.MoveTo(pt)
pen = true
} else {
p.LineTo(pt)
}
}
paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: float32(gtx.Dp(1.3))}.Op())
for _, sp := range points {
if sp.OK {
continue
}
pt := pos(plot, tMin, tMax, yMax, ChartPoint{At: sp.At, Value: 0, OK: true})
x := int(pt.X)
paint.FillShape(gtx.Ops, WithAlpha(t.P.Fail, 0.8), clip.Rect{
Min: image.Pt(x, plot.Max.Y-gtx.Dp(3)),
Max: image.Pt(x+1, plot.Max.Y),
}.Op())
}
return D{Size: size}
}
+241
View File
@@ -0,0 +1,241 @@
package gui
import (
"io/fs"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"gioui.org/font"
"gioui.org/font/gofont"
"gioui.org/font/opentype"
)
// FontSet is the typeface configuration the theme is built from.
//
// Gio v0.10 already consults the operating system's fonts through go-text's
// fontscan, which handles CJK fallback on a well-configured desktop. We do not
// rely on that alone: minimal Linux images (containers, netboot, some NAS
// distros) ship a broken or empty font index, and the failure mode there is a
// window full of tofu boxes with no explanation. So we additionally locate a
// CJK font file ourselves and load it explicitly.
type FontSet struct {
Collection []font.FontFace
UI font.Typeface
Mono font.Typeface
// HasCJK reports whether Chinese text can be rendered. It drives the
// default UI language: showing Chinese labels we cannot draw is worse than
// showing English ones.
HasCJK bool
// CJKPath is the font file backing HasCJK, for display in the about panel.
CJKPath string
}
// LoadFonts builds the initial font set. It is deliberately cheap — only a
// handful of os.Stat calls — so the window can open immediately. The actual
// CJK font file is parsed later by [LoadCJKFaces] while the splash screen is
// up.
func LoadFonts() *FontSet {
fs := &FontSet{
Collection: gofont.Collection(),
UI: "Go",
Mono: "Go Mono",
}
if path, ok := FindCJKFont(); ok {
fs.HasCJK = true
fs.CJKPath = path
}
return fs
}
// cjkCandidates returns absolute font paths to try, best first. Smaller
// single-script files come before the big pan-CJK collections: parsing a 20 MB
// .ttc costs a few hundred milliseconds and five faces we will never use.
func cjkCandidates() []string {
switch runtime.GOOS {
case "windows":
dirs := []string{}
if w := os.Getenv("WINDIR"); w != "" {
dirs = append(dirs, filepath.Join(w, "Fonts"))
}
if l := os.Getenv("LOCALAPPDATA"); l != "" {
dirs = append(dirs, filepath.Join(l, "Microsoft", "Windows", "Fonts"))
}
names := []string{
"msyh.ttc", "msyh.ttf", // 微软雅黑
"msyhl.ttc", "Deng.ttf", // 等线
"simhei.ttf", // 黑体
"simsun.ttc", "simsun.ttf",
"msjh.ttc", // 微軟正黑體
}
var out []string
for _, d := range dirs {
for _, n := range names {
out = append(out, filepath.Join(d, n))
}
}
return out
case "darwin":
return []string{
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"/System/Library/Fonts/Supplemental/Songti.ttc",
}
default: // linux, bsd
return []string{
// Debian/Ubuntu single-script Noto, the cheapest good option.
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf",
// Fedora/Arch layouts.
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/adobe-source-han-sans/SourceHanSansSC-Regular.otf",
"/usr/share/fonts/opentype/source-han-sans/SourceHanSansSC-Regular.otf",
// Lightweight fallbacks common on embedded/NAS systems.
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/truetype/droid/DroidSansFallback.ttf",
}
}
}
// fontSearchDirs are walked when no candidate path matched.
func fontSearchDirs() []string {
var dirs []string
switch runtime.GOOS {
case "windows":
if w := os.Getenv("WINDIR"); w != "" {
dirs = append(dirs, filepath.Join(w, "Fonts"))
}
case "darwin":
dirs = append(dirs, "/System/Library/Fonts", "/Library/Fonts")
default:
dirs = append(dirs, "/usr/share/fonts", "/usr/local/share/fonts")
}
if home, err := os.UserHomeDir(); err == nil {
switch runtime.GOOS {
case "darwin":
dirs = append(dirs, filepath.Join(home, "Library", "Fonts"))
case "windows":
default:
dirs = append(dirs, filepath.Join(home, ".local", "share", "fonts"), filepath.Join(home, ".fonts"))
}
}
return dirs
}
// cjkNameHints match filenames of fonts known to carry Han glyphs.
var cjkNameHints = []string{
"notosanscjk", "notoserifcjk", "notosanssc", "notosanstc", "notosanshk",
"sourcehansans", "sourcehanserif", "wqy-microhei", "wqy-zenhei",
"droidsansfallback", "msyh", "simhei", "simsun", "pingfang", "hiragino",
"stheiti", "unifont", "arphic", "uming", "ukai", "microhei", "zenhei",
"opposans", "harmonyos_sans_sc", "arialuni",
}
// FindCJKFont locates a font file with Chinese coverage. The walk is bounded so
// a pathological font directory cannot stall startup.
func FindCJKFont() (string, bool) {
for _, p := range cjkCandidates() {
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Size() > 0 {
return p, true
}
}
deadline := time.Now().Add(600 * time.Millisecond)
seen := 0
for _, dir := range fontSearchDirs() {
var found string
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // unreadable subtree, keep going
}
if seen++; seen > 20000 || time.Now().After(deadline) {
return filepath.SkipAll
}
if d.IsDir() {
return nil
}
name := strings.ToLower(d.Name())
switch {
case strings.HasSuffix(name, ".ttf"),
strings.HasSuffix(name, ".ttc"),
strings.HasSuffix(name, ".otf"),
strings.HasSuffix(name, ".otc"):
default:
return nil
}
for _, hint := range cjkNameHints {
if strings.Contains(name, hint) {
found = path
return filepath.SkipAll
}
}
return nil
})
if found != "" {
return found, true
}
}
return "", false
}
// maxFontBytes caps how large a font file we are willing to read. Pan-CJK
// collections run to ~40 MB; anything beyond that is not a font we want.
const maxFontBytes = 64 << 20
// LoadCJKFaces parses the font file at path and returns its faces, ready to be
// appended to a collection. It is slow enough (tens to hundreds of
// milliseconds) that callers should run it off the UI goroutine — which is
// exactly what the splash screen exists for.
func LoadCJKFaces(path string, logger *slog.Logger) ([]font.FontFace, error) {
if logger == nil {
logger = slog.Default()
}
st, err := os.Stat(path)
if err != nil {
return nil, err
}
if st.Size() > maxFontBytes {
logger.Warn("cjk font too large, skipping", "path", path, "bytes", st.Size())
return nil, nil
}
start := time.Now()
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
faces, err := opentype.ParseCollection(data)
if err != nil {
return nil, err
}
// A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping
// only the first regular-weight face avoids paying for five near-identical
// fallbacks on every glyph miss.
if len(faces) > 1 {
faces = faces[:1]
}
logger.Debug("cjk font loaded",
"path", path,
"faces", len(faces),
"bytes", st.Size(),
"took", time.Since(start).Round(time.Millisecond),
)
return faces, nil
}
// goCollection returns the built-in Go font faces. It exists so tests can
// build a theme without touching the host's font configuration.
func goCollection() []font.FontFace { return gofont.Collection() }
+631
View File
@@ -0,0 +1,631 @@
package gui
// Lang selects the UI label set. Chinese is the project's primary audience;
// English exists because a machine without a CJK font cannot draw Chinese, and
// silently rendering tofu boxes would be worse than translating.
type Lang int
const (
LangZH Lang = iota
LangEN
)
// Name is the language's own name, for the settings toggle.
func (l Lang) Name() string {
if l == LangEN {
return "English"
}
return "中文"
}
// Key identifies a translatable string.
type Key int
const (
KAppTitle Key = iota
KAppSubtitle
// Navigation.
KNavOverview
KNavPeers
KNavLan
KNavDiag
KNavLogs
KNavSettings
// Service lifecycle.
KStateStarting
KStateConnecting
KStateRunning
KStateDegraded
KStateStopped
KStateError
KStateRetrying
// Splash steps.
KStepConfig
KStepFonts
KStepTsnet
KStepRules
KStepDiscovery
KStepMonitors
KStepReady
KSplashHint
KSplashLogHint
KSplashRetry
// Shared vocabulary.
KYes
KNo
KUnknown
KSupported
KUnsupported
KEnabled
KDisabled
KNone
KRefresh
KRetry
KClose
KCopy
KCopied
KDetails
KLoading
KError
KNever
KJustNow
KSecondsAgo
KMinutesAgo
KHoursAgo
KTotal
KOnline
KOffline
// Overview.
KOvTailnet
KOvSelf
KOvPeersOnline
KOvLanServers
KOvForwardRules
KOvConnectRules
KOvUptime
KOvHealth
KOvQuickDiag
KOvNoIssues
// Peers page.
KPeersTitle
KPeersLinked
KPeersOther
KPeersEmpty
KPeerLatency
KPeerRoute
KPeerRouteDirect
KPeerRouteDERP
KPeerRoutePeerRelay
KPeerRouteOffline
KPeerRouteUnknown
KPeerAvg
KPeerMin
KPeerMax
KPeerJitter
KPeerLoss
KPeerRx
KPeerTx
KPeerLastSeen
KPeerLastHandshake
KPeerAddresses
KPeerEndpoint
KPeerOS
KPeerExitNode
KPeerTags
KGraphTitle
KGraphEmpty
KGraphWindow
KGraphLegendHint
// LAN page.
KLanTitle
KLanSubtitle
KLanEmpty
KLanListening
KLanMotd
KLanPort
KLanAddress
KLanSeen
KLanSelf
KLanSelfHint
KLanPackets
KLanBindError
// Diagnostics page.
KDiagTitle
KDiagRun
KDiagRunning
KDiagRerun
KDiagNever
KDiagLastRun
KDiagCopyReport
KDiagSecIface
KDiagSecUDP
KDiagSecNAT
KDiagSecPortMap
KDiagSecOverseas
KDiagSecEgress
KDiagSecTailscale
KDiagNatType
KDiagNatMapping
KDiagNatFiltering
KDiagNatHairpin
KDiagNatPortPreserve
KDiagUdpV4
KDiagUdpV6
KDiagUdpPortsOK
KDiagUdpPortsBlocked
KDiagIfaceDefaultV4
KDiagIfaceDefaultV6
KDiagUPnP
KDiagNATPMP
KDiagPCP
KDiagGateway
KDiagExternalIP
KDiagOverseasTarget
KDiagEgressMethod
KDiagEgressIP
KDiagEgressGeo
KDiagEgressDivergent
KDiagEgressDivergentHint
KDiagGeoSkipped
KDiagPreferredDERP
KDiagDerpLatency
KDiagCaptivePortal
KDiagMappingVaries
KDiagSkipGeo
KDiagSkipGeoHint
// NAT names.
KNatOpen
KNatFullCone
KNatRestricted
KNatPortRestricted
KNatSymmetric
KNatUDPBlocked
KNatSymmetricFW
KNatUnknown
// Logs page.
KLogsTitle
KLogsSearch
KLogsLevel
KLogsSource
KLogsFollow
KLogsAll
KLogsEmpty
KLogsCopyAll
KLogsSaveFile
KLogsUpload
KLogsUploading
KLogsUploaded
KLogsUploadFail
KLogsRedact
KLogsRedactHint
KLogsShown
KLogsDropped
KLogsIncludeDiag
KLogsOpenOverlay
// Settings.
KSetTheme
KSetThemeDark
KSetThemeLight
KSetLanguage
KSetAbout
KSetConfigPath
KSetVersion
KSetFont
KSetFontMissing
kCount
)
var zhStrings = [kCount]string{
KAppTitle: "tslink",
KAppSubtitle: "Tailscale 内网穿透",
KNavOverview: "概览",
KNavPeers: "节点",
KNavLan: "局域网",
KNavDiag: "网络诊断",
KNavLogs: "日志",
KNavSettings: "设置",
KStateStarting: "正在启动",
KStateConnecting: "正在连接",
KStateRunning: "运行中",
KStateDegraded: "降级运行",
KStateStopped: "已停止",
KStateError: "出错",
KStateRetrying: "正在重试",
KStepConfig: "读取配置",
KStepFonts: "加载字体",
KStepTsnet: "接入 Tailscale 网络",
KStepRules: "解析转发规则",
KStepDiscovery: "启动局域网发现",
KStepMonitors: "启动状态监控",
KStepReady: "准备就绪",
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
KSplashLogHint: "实时日志(截图时可一并保留)",
KSplashRetry: "启动失败,正在重试",
KYes: "是",
KNo: "否",
KUnknown: "未知",
KSupported: "支持",
KUnsupported: "不支持",
KEnabled: "已启用",
KDisabled: "已禁用",
KNone: "无",
KRefresh: "刷新",
KRetry: "重试",
KClose: "关闭",
KCopy: "复制",
KCopied: "已复制",
KDetails: "详情",
KLoading: "加载中",
KError: "错误",
KNever: "从未",
KJustNow: "刚刚",
KSecondsAgo: "秒前",
KMinutesAgo: "分钟前",
KHoursAgo: "小时前",
KTotal: "共",
KOnline: "在线",
KOffline: "离线",
KOvTailnet: "Tailnet",
KOvSelf: "本机",
KOvPeersOnline: "在线节点",
KOvLanServers: "局域网服务器",
KOvForwardRules: "转发规则",
KOvConnectRules: "连接规则",
KOvUptime: "运行时长",
KOvHealth: "健康状况",
KOvQuickDiag: "运行网络诊断",
KOvNoIssues: "未发现问题",
KPeersTitle: "Tailscale 节点",
KPeersLinked: "已关联",
KPeersOther: "其他节点",
KPeersEmpty: "暂无节点",
KPeerLatency: "延迟",
KPeerRoute: "链路",
KPeerRouteDirect: "直连",
KPeerRouteDERP: "DERP 中继",
KPeerRoutePeerRelay: "对等中继",
KPeerRouteOffline: "离线",
KPeerRouteUnknown: "未知",
KPeerAvg: "平均",
KPeerMin: "最低",
KPeerMax: "最高",
KPeerJitter: "抖动",
KPeerLoss: "丢包",
KPeerRx: "接收",
KPeerTx: "发送",
KPeerLastSeen: "最后在线",
KPeerLastHandshake: "最后握手",
KPeerAddresses: "地址",
KPeerEndpoint: "端点",
KPeerOS: "系统",
KPeerExitNode: "出口节点",
KPeerTags: "标签",
KGraphTitle: "延迟图谱",
KGraphEmpty: "正在采集延迟数据",
KGraphWindow: "最近 20 分钟",
KGraphLegendHint: "点击图例可隐藏对应节点",
KLanTitle: "局域网 Minecraft 服务器",
KLanSubtitle: "监听 224.0.2.60:4445 的广播",
KLanEmpty: "未发现局域网服务器",
KLanListening: "监听中",
KLanMotd: "服务器名称",
KLanPort: "端口",
KLanAddress: "地址",
KLanSeen: "最后广播",
KLanSelf: "本机广播",
KLanSelfHint: "由 tslink 转发并广播,说明隧道已生效",
KLanPackets: "收包",
KLanBindError: "无法监听组播",
KDiagTitle: "网络诊断",
KDiagRun: "开始诊断",
KDiagRunning: "诊断中",
KDiagRerun: "重新诊断",
KDiagNever: "尚未运行诊断",
KDiagLastRun: "上次运行",
KDiagCopyReport: "复制诊断报告",
KDiagSecIface: "本机出口地址",
KDiagSecUDP: "UDP 连通性",
KDiagSecNAT: "NAT 类型",
KDiagSecPortMap: "端口映射",
KDiagSecOverseas: "境外连通性",
KDiagSecEgress: "出口 IP 与归属地",
KDiagSecTailscale: "Tailscale 内部状态",
KDiagNatType: "NAT 类型",
KDiagNatMapping: "映射行为",
KDiagNatFiltering: "过滤行为",
KDiagNatHairpin: "发夹回环",
KDiagNatPortPreserve: "端口保持",
KDiagUdpV4: "IPv4 UDP",
KDiagUdpV6: "IPv6 UDP",
KDiagUdpPortsOK: "可用端口",
KDiagUdpPortsBlocked: "被封端口",
KDiagIfaceDefaultV4: "默认 IPv4 源地址",
KDiagIfaceDefaultV6: "默认 IPv6 源地址",
KDiagUPnP: "UPnP IGD",
KDiagNATPMP: "NAT-PMP",
KDiagPCP: "PCP",
KDiagGateway: "网关",
KDiagExternalIP: "外部地址",
KDiagOverseasTarget: "测试目标",
KDiagEgressMethod: "探测方式",
KDiagEgressIP: "出口 IP",
KDiagEgressGeo: "归属地",
KDiagEgressDivergent: "出口不一致",
KDiagEgressDivergentHint: "不同探测方式得到了不同的公网 IP,通常说明有代理或分流工具在生效",
KDiagGeoSkipped: "已跳过归属地查询",
KDiagPreferredDERP: "首选 DERP",
KDiagDerpLatency: "DERP 延迟",
KDiagCaptivePortal: "门户劫持",
KDiagMappingVaries: "映射随目标变化",
KDiagSkipGeo: "不查询归属地",
KDiagSkipGeoHint: "归属地查询会把你的公网 IP 发送给第三方服务",
KNatOpen: "开放网络",
KNatFullCone: "完全锥形",
KNatRestricted: "地址限制锥形",
KNatPortRestricted: "端口限制锥形",
KNatSymmetric: "对称型",
KNatUDPBlocked: "UDP 被阻断",
KNatSymmetricFW: "对称型防火墙",
KNatUnknown: "无法判定",
KLogsTitle: "日志",
KLogsSearch: "搜索日志…",
KLogsLevel: "级别",
KLogsSource: "来源",
KLogsFollow: "自动跟随",
KLogsAll: "全部",
KLogsEmpty: "没有匹配的日志",
KLogsCopyAll: "复制到剪贴板",
KLogsSaveFile: "保存到文件",
KLogsUpload: "上传并分享",
KLogsUploading: "正在上传",
KLogsUploaded: "上传成功,链接已复制",
KLogsUploadFail: "上传失败",
KLogsRedact: "隐去密钥",
KLogsRedactHint: "上传前会自动隐去 authkey 等凭据",
KLogsShown: "已显示",
KLogsDropped: "条早期日志已被丢弃",
KLogsIncludeDiag: "附带诊断报告",
KLogsOpenOverlay: "浮层日志",
KSetTheme: "主题",
KSetThemeDark: "深色",
KSetThemeLight: "浅色",
KSetLanguage: "语言",
KSetAbout: "关于",
KSetConfigPath: "配置文件",
KSetVersion: "版本",
KSetFont: "中文字体",
KSetFontMissing: "未找到中文字体,界面已切换为英文",
}
var enStrings = [kCount]string{
KAppTitle: "tslink",
KAppSubtitle: "Tailscale link layer",
KNavOverview: "Overview",
KNavPeers: "Peers",
KNavLan: "LAN",
KNavDiag: "Diagnostics",
KNavLogs: "Logs",
KNavSettings: "Settings",
KStateStarting: "Starting",
KStateConnecting: "Connecting",
KStateRunning: "Running",
KStateDegraded: "Degraded",
KStateStopped: "Stopped",
KStateError: "Error",
KStateRetrying: "Retrying",
KStepConfig: "Loading configuration",
KStepFonts: "Loading fonts",
KStepTsnet: "Joining the tailnet",
KStepRules: "Resolving forward rules",
KStepDiscovery: "Starting LAN discovery",
KStepMonitors: "Starting monitors",
KStepReady: "Ready",
KSplashHint: "The first tailnet join can take a dozen seconds",
KSplashLogHint: "Live log (stays visible in screenshots)",
KSplashRetry: "Startup failed, retrying",
KYes: "Yes",
KNo: "No",
KUnknown: "Unknown",
KSupported: "Supported",
KUnsupported: "Not supported",
KEnabled: "Enabled",
KDisabled: "Disabled",
KNone: "None",
KRefresh: "Refresh",
KRetry: "Retry",
KClose: "Close",
KCopy: "Copy",
KCopied: "Copied",
KDetails: "Details",
KLoading: "Loading",
KError: "Error",
KNever: "Never",
KJustNow: "just now",
KSecondsAgo: "s ago",
KMinutesAgo: "m ago",
KHoursAgo: "h ago",
KTotal: "Total",
KOnline: "Online",
KOffline: "Offline",
KOvTailnet: "Tailnet",
KOvSelf: "This node",
KOvPeersOnline: "Peers online",
KOvLanServers: "LAN servers",
KOvForwardRules: "Forward rules",
KOvConnectRules: "Connect rules",
KOvUptime: "Uptime",
KOvHealth: "Health",
KOvQuickDiag: "Run diagnostics",
KOvNoIssues: "No issues found",
KPeersTitle: "Tailscale peers",
KPeersLinked: "Linked",
KPeersOther: "Other peers",
KPeersEmpty: "No peers yet",
KPeerLatency: "Latency",
KPeerRoute: "Route",
KPeerRouteDirect: "Direct",
KPeerRouteDERP: "DERP relay",
KPeerRoutePeerRelay: "Peer relay",
KPeerRouteOffline: "Offline",
KPeerRouteUnknown: "Unknown",
KPeerAvg: "avg",
KPeerMin: "min",
KPeerMax: "max",
KPeerJitter: "jitter",
KPeerLoss: "loss",
KPeerRx: "Rx",
KPeerTx: "Tx",
KPeerLastSeen: "Last seen",
KPeerLastHandshake: "Last handshake",
KPeerAddresses: "Addresses",
KPeerEndpoint: "Endpoint",
KPeerOS: "OS",
KPeerExitNode: "Exit node",
KPeerTags: "Tags",
KGraphTitle: "Latency graph",
KGraphEmpty: "Collecting latency samples",
KGraphWindow: "last 20 minutes",
KGraphLegendHint: "Click a legend entry to hide that peer",
KLanTitle: "Minecraft servers on the LAN",
KLanSubtitle: "Listening for broadcasts on 224.0.2.60:4445",
KLanEmpty: "No LAN servers discovered",
KLanListening: "Listening",
KLanMotd: "Name",
KLanPort: "Port",
KLanAddress: "Address",
KLanSeen: "Last broadcast",
KLanSelf: "Ours",
KLanSelfHint: "Advertised by tslink, so the tunnel is working",
KLanPackets: "packets",
KLanBindError: "Cannot join multicast group",
KDiagTitle: "Network diagnostics",
KDiagRun: "Run diagnostics",
KDiagRunning: "Running",
KDiagRerun: "Run again",
KDiagNever: "Not run yet",
KDiagLastRun: "Last run",
KDiagCopyReport: "Copy report",
KDiagSecIface: "Local egress addresses",
KDiagSecUDP: "UDP connectivity",
KDiagSecNAT: "NAT type",
KDiagSecPortMap: "Port mapping",
KDiagSecOverseas: "Overseas reachability",
KDiagSecEgress: "Egress IP and geolocation",
KDiagSecTailscale: "Tailscale internals",
KDiagNatType: "NAT type",
KDiagNatMapping: "Mapping behaviour",
KDiagNatFiltering: "Filtering behaviour",
KDiagNatHairpin: "Hairpinning",
KDiagNatPortPreserve: "Port preserving",
KDiagUdpV4: "IPv4 UDP",
KDiagUdpV6: "IPv6 UDP",
KDiagUdpPortsOK: "Reachable ports",
KDiagUdpPortsBlocked: "Blocked ports",
KDiagIfaceDefaultV4: "Default IPv4 source",
KDiagIfaceDefaultV6: "Default IPv6 source",
KDiagUPnP: "UPnP IGD",
KDiagNATPMP: "NAT-PMP",
KDiagPCP: "PCP",
KDiagGateway: "Gateway",
KDiagExternalIP: "External address",
KDiagOverseasTarget: "Target",
KDiagEgressMethod: "Method",
KDiagEgressIP: "Egress IP",
KDiagEgressGeo: "Location",
KDiagEgressDivergent: "Egress mismatch",
KDiagEgressDivergentHint: "Different probes saw different public IPs, which usually means a proxy or split tunnel is active",
KDiagGeoSkipped: "Geolocation skipped",
KDiagPreferredDERP: "Preferred DERP",
KDiagDerpLatency: "DERP latency",
KDiagCaptivePortal: "Captive portal",
KDiagMappingVaries: "Mapping varies by destination",
KDiagSkipGeo: "Skip geolocation",
KDiagSkipGeoHint: "Geolocation sends your public IP to a third-party service",
KNatOpen: "Open internet",
KNatFullCone: "Full cone",
KNatRestricted: "Address-restricted cone",
KNatPortRestricted: "Port-restricted cone",
KNatSymmetric: "Symmetric",
KNatUDPBlocked: "UDP blocked",
KNatSymmetricFW: "Symmetric firewall",
KNatUnknown: "Undetermined",
KLogsTitle: "Logs",
KLogsSearch: "Search logs…",
KLogsLevel: "Level",
KLogsSource: "Source",
KLogsFollow: "Follow",
KLogsAll: "All",
KLogsEmpty: "No matching log entries",
KLogsCopyAll: "Copy to clipboard",
KLogsSaveFile: "Save to file",
KLogsUpload: "Upload and share",
KLogsUploading: "Uploading",
KLogsUploaded: "Uploaded, link copied",
KLogsUploadFail: "Upload failed",
KLogsRedact: "Redact secrets",
KLogsRedactHint: "Credentials such as authkeys are removed before upload",
KLogsShown: "shown",
KLogsDropped: "earlier entries were dropped",
KLogsIncludeDiag: "Include diagnostics",
KLogsOpenOverlay: "Log overlay",
KSetTheme: "Theme",
KSetThemeDark: "Dark",
KSetThemeLight: "Light",
KSetLanguage: "Language",
KSetAbout: "About",
KSetConfigPath: "Config file",
KSetVersion: "Version",
KSetFont: "CJK font",
KSetFontMissing: "No CJK font found, the UI fell back to English",
}
// Tr returns the localised string for k, falling back to English and then to a
// visible placeholder rather than an empty label.
func Tr(l Lang, k Key) string {
if k < 0 || k >= kCount {
return "?"
}
if l == LangZH {
if s := zhStrings[k]; s != "" {
return s
}
}
if s := enStrings[k]; s != "" {
return s
}
return "?"
}
+367
View File
@@ -0,0 +1,367 @@
package gui
import (
"image"
"image/color"
"math"
"gioui.org/f32"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
// IconFunc draws an icon of the given pixel size in col, occupying a square of
// that size.
//
// The icons are drawn as vector line art rather than pulled from an icon font
// or the shiny material set: a dozen hand-drawn paths keep the binary small,
// avoid a dependency, and let every glyph share one stroke weight so the
// toolbar reads as a set.
type IconFunc func(gtx C, size int, col color.NRGBA) D
// defaultStroke is the icon stroke width as a fraction of the icon box.
const defaultStroke = 0.085
// iconCanvas sets up a unit coordinate space (0..1 in both axes) and strokes
// whatever the draw function puts on the path.
func iconCanvas(gtx C, size int, col color.NRGBA, width float32, draw func(p *clip.Path, pt func(x, y float32) f32.Point)) D {
if size <= 0 {
return D{}
}
s := float32(size)
pt := func(x, y float32) f32.Point { return f32.Pt(x*s, y*s) }
var p clip.Path
p.Begin(gtx.Ops)
draw(&p, pt)
w := width * s
if w < 1 {
w = 1
}
paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: w}.Op())
return D{Size: image.Pt(size, size)}
}
// arcAt appends a circle (or arc) centred at (cx, cy) with radius r, in unit
// coordinates.
func arcAt(p *clip.Path, pt func(x, y float32) f32.Point, cx, cy, r, startAngle, sweep float32) {
start := pt(
cx+r*float32(math.Cos(float64(startAngle))),
cy+r*float32(math.Sin(float64(startAngle))),
)
c := pt(cx, cy)
p.MoveTo(start)
d := c.Sub(start)
p.Arc(d, d, sweep)
}
func poly(p *clip.Path, pt func(x, y float32) f32.Point, pts ...[2]float32) {
if len(pts) == 0 {
return
}
p.MoveTo(pt(pts[0][0], pts[0][1]))
for _, q := range pts[1:] {
p.LineTo(pt(q[0], q[1]))
}
}
func line(p *clip.Path, pt func(x, y float32) f32.Point, x1, y1, x2, y2 float32) {
p.MoveTo(pt(x1, y1))
p.LineTo(pt(x2, y2))
}
func rect(p *clip.Path, pt func(x, y float32) f32.Point, x, y, w, h float32) {
p.MoveTo(pt(x, y))
p.LineTo(pt(x+w, y))
p.LineTo(pt(x+w, y+h))
p.LineTo(pt(x, y+h))
p.Close()
}
// dot paints a filled circle in unit coordinates, for icons that need a solid
// node rather than an outline.
func dot(gtx C, size int, col color.NRGBA, cx, cy, r float32) {
s := float32(size)
d := int(2 * r * s)
if d < 2 {
d = 2
}
off := op.Offset(image.Pt(int(cx*s)-d/2, int(cy*s)-d/2)).Push(gtx.Ops)
Circle(gtx, d, col)
off.Pop()
}
// ---------------------------------------------------------------------------
// Navigation icons
// ---------------------------------------------------------------------------
// IconGrid is the overview page: four panes.
func IconGrid(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
rect(p, pt, 0.14, 0.14, 0.30, 0.30)
rect(p, pt, 0.56, 0.14, 0.30, 0.30)
rect(p, pt, 0.14, 0.56, 0.30, 0.30)
rect(p, pt, 0.56, 0.56, 0.30, 0.30)
})
}
// IconNodes is the peers page: three linked nodes.
func IconNodes(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.50, 0.24, 0.22, 0.72)
line(p, pt, 0.50, 0.24, 0.78, 0.72)
line(p, pt, 0.22, 0.72, 0.78, 0.72)
})
dot(gtx, size, col, 0.50, 0.22, 0.13)
dot(gtx, size, col, 0.21, 0.75, 0.13)
dot(gtx, size, col, 0.79, 0.75, 0.13)
return d
}
// IconBroadcast is the LAN page: a source radiating outwards.
func IconBroadcast(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
const q = math.Pi / 4
arcAt(p, pt, 0.5, 0.5, 0.22, -q, 2*q)
arcAt(p, pt, 0.5, 0.5, 0.40, -q, 2*q)
arcAt(p, pt, 0.5, 0.5, 0.22, float32(math.Pi)-q, 2*q)
arcAt(p, pt, 0.5, 0.5, 0.40, float32(math.Pi)-q, 2*q)
})
dot(gtx, size, col, 0.5, 0.5, 0.12)
return d
}
// IconPulse is the diagnostics page: an activity trace.
func IconPulse(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
poly(p, pt,
[2]float32{0.08, 0.52},
[2]float32{0.28, 0.52},
[2]float32{0.40, 0.22},
[2]float32{0.56, 0.80},
[2]float32{0.68, 0.52},
[2]float32{0.92, 0.52},
)
})
}
// IconList is the logs page.
func IconList(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.16, 0.28, 0.84, 0.28)
line(p, pt, 0.16, 0.50, 0.84, 0.50)
line(p, pt, 0.16, 0.72, 0.60, 0.72)
})
}
// IconSliders is the settings page.
func IconSliders(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.12, 0.30, 0.88, 0.30)
line(p, pt, 0.12, 0.70, 0.88, 0.70)
})
dot(gtx, size, col, 0.34, 0.30, 0.13)
dot(gtx, size, col, 0.66, 0.70, 0.13)
return d
}
// ---------------------------------------------------------------------------
// Action icons
// ---------------------------------------------------------------------------
// IconCopy is the copy-to-clipboard action.
func IconCopy(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
rect(p, pt, 0.32, 0.32, 0.54, 0.54)
poly(p, pt,
[2]float32{0.68, 0.20},
[2]float32{0.14, 0.20},
[2]float32{0.14, 0.68},
)
})
}
// IconUpload is the share/upload action.
func IconUpload(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.5, 0.16, 0.5, 0.64)
poly(p, pt,
[2]float32{0.30, 0.36},
[2]float32{0.50, 0.16},
[2]float32{0.70, 0.36},
)
poly(p, pt,
[2]float32{0.16, 0.62},
[2]float32{0.16, 0.86},
[2]float32{0.84, 0.86},
[2]float32{0.84, 0.62},
)
})
}
// IconSave is the write-to-disk action.
func IconSave(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.5, 0.14, 0.5, 0.62)
poly(p, pt,
[2]float32{0.30, 0.42},
[2]float32{0.50, 0.62},
[2]float32{0.70, 0.42},
)
poly(p, pt,
[2]float32{0.16, 0.62},
[2]float32{0.16, 0.86},
[2]float32{0.84, 0.86},
[2]float32{0.84, 0.62},
)
})
}
// IconRefresh is the re-run action.
func IconRefresh(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
arcAt(p, pt, 0.5, 0.5, 0.32, -1.9, 4.9)
poly(p, pt,
[2]float32{0.60, 0.06},
[2]float32{0.61, 0.30},
[2]float32{0.38, 0.24},
)
})
}
// IconCheck marks a passed check.
func IconCheck(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, 0.11, func(p *clip.Path, pt func(x, y float32) f32.Point) {
poly(p, pt,
[2]float32{0.18, 0.52},
[2]float32{0.42, 0.74},
[2]float32{0.82, 0.28},
)
})
}
// IconWarn marks a warning.
func IconWarn(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
poly(p, pt,
[2]float32{0.50, 0.12},
[2]float32{0.92, 0.84},
[2]float32{0.08, 0.84},
)
p.Close()
line(p, pt, 0.5, 0.40, 0.5, 0.60)
})
dot(gtx, size, col, 0.5, 0.72, 0.055)
return d
}
// IconClose dismisses an overlay.
func IconClose(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.24, 0.24, 0.76, 0.76)
line(p, pt, 0.76, 0.24, 0.24, 0.76)
})
}
// IconChevronRight indicates an expandable row.
func IconChevronRight(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
poly(p, pt,
[2]float32{0.40, 0.24},
[2]float32{0.66, 0.50},
[2]float32{0.40, 0.76},
)
})
}
// IconChevronDown indicates an expanded row.
func IconChevronDown(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
poly(p, pt,
[2]float32{0.24, 0.40},
[2]float32{0.50, 0.66},
[2]float32{0.76, 0.40},
)
})
}
// IconSearch prefixes the log filter field.
func IconSearch(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
arcAt(p, pt, 0.44, 0.44, 0.28, 0, 2*math.Pi)
line(p, pt, 0.64, 0.64, 0.86, 0.86)
})
}
// IconGlobe marks anything about the public internet.
func IconGlobe(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
arcAt(p, pt, 0.5, 0.5, 0.38, 0, 2*math.Pi)
line(p, pt, 0.12, 0.5, 0.88, 0.5)
// Two meridians, drawn as opposing quadratic bows.
p.MoveTo(pt(0.5, 0.12))
p.QuadTo(pt(0.22, 0.5), pt(0.5, 0.88))
p.MoveTo(pt(0.5, 0.12))
p.QuadTo(pt(0.78, 0.5), pt(0.5, 0.88))
})
}
// IconServer marks a discovered game server.
func IconServer(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
rect(p, pt, 0.14, 0.18, 0.72, 0.26)
rect(p, pt, 0.14, 0.56, 0.72, 0.26)
})
dot(gtx, size, col, 0.26, 0.31, 0.05)
dot(gtx, size, col, 0.26, 0.69, 0.05)
return d
}
// IconLink marks a peer referenced by a config rule.
func IconLink(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
arcAt(p, pt, 0.34, 0.66, 0.22, -2.36, 3.14)
arcAt(p, pt, 0.66, 0.34, 0.22, 0.78, 3.14)
line(p, pt, 0.38, 0.62, 0.62, 0.38)
})
}
// IconShield marks NAT and firewall findings.
func IconShield(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
p.MoveTo(pt(0.5, 0.10))
p.LineTo(pt(0.84, 0.24))
p.LineTo(pt(0.84, 0.52))
p.QuadTo(pt(0.84, 0.80), pt(0.5, 0.92))
p.QuadTo(pt(0.16, 0.80), pt(0.16, 0.52))
p.LineTo(pt(0.16, 0.24))
p.Close()
})
}
// IconRouter marks port-mapping results.
func IconRouter(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
rect(p, pt, 0.10, 0.54, 0.80, 0.30)
line(p, pt, 0.32, 0.54, 0.32, 0.34)
line(p, pt, 0.32, 0.34, 0.62, 0.20)
line(p, pt, 0.68, 0.54, 0.68, 0.30)
})
dot(gtx, size, col, 0.24, 0.69, 0.05)
dot(gtx, size, col, 0.40, 0.69, 0.05)
return d
}
// IconRoute marks the local-interface section.
func IconRoute(gtx C, size int, col color.NRGBA) D {
d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
p.MoveTo(pt(0.22, 0.78))
p.QuadTo(pt(0.22, 0.50), pt(0.50, 0.50))
p.QuadTo(pt(0.78, 0.50), pt(0.78, 0.22))
})
dot(gtx, size, col, 0.22, 0.80, 0.11)
dot(gtx, size, col, 0.78, 0.20, 0.11)
return d
}
+269
View File
@@ -0,0 +1,269 @@
package gui
import (
"log/slog"
"strings"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/unit"
"gioui.org/widget"
"tslink/core"
)
// logOverlay is the translucent live-log panel.
//
// It exists for one specific situation: someone is looking at a stuck loading
// screen and takes a screenshot to ask for help. If the logs are on another
// page, that screenshot is useless. Rendering them as a translucent sheet over
// the loading screen means the interesting information is in the picture
// without hiding what the app is doing.
type logOverlay struct {
visible bool
list layout.List
copyBtn widget.Clickable
closeBtn widget.Clickable
// docked is set while the splash is up: the panel then spans the window
// bottom instead of floating in the corner.
docked bool
// Cached tail. The overlay redraws at the animation rate because of its
// live status dot, but the log only changes when a record is appended, so
// the slice is rebuilt on sequence change rather than every frame.
cached []core.LogEntry
cachedSeq uint64
cachedLen int
}
// tail returns the newest records, rebuilding only when the buffer advanced.
func (o *logOverlay) tail(buf *core.LogBuffer) []core.LogEntry {
seq, n := buf.LastSeq(), buf.Len()
if o.cached != nil && seq == o.cachedSeq && n == o.cachedLen {
return o.cached
}
o.cached = buf.Tail(overlayTailSize)
o.cachedSeq, o.cachedLen = seq, n
return o.cached
}
func newLogOverlay() *logOverlay {
return &logOverlay{
list: layout.List{Axis: layout.Vertical, ScrollToEnd: true},
}
}
// overlayTailSize is how many recent records the overlay renders. The full
// history lives on the logs page; this is a live tail, not an archive.
const overlayTailSize = 400
// Docked geometry. The splash reserves exactly this much room at the bottom of
// the window so the checklist is never hidden behind the log sheet — the point
// of the overlay is that both are legible in one screenshot.
const (
dockedLogHeight unit.Dp = 176
dockedHeaderHeight unit.Dp = 28
)
// dockedReserve is the total vertical space the docked overlay occupies,
// including its insets and the margin below it.
func dockedReserve(gtx C) int {
return gtx.Dp(dockedLogHeight + dockedHeaderHeight + SpaceSM + SpaceMD*2 + SpaceXL)
}
// Layout draws the overlay. duringSplash forces it visible and docked.
func (o *logOverlay) Layout(a *App, gtx C, duringSplash bool) D {
o.docked = duringSplash
if !duringSplash && !o.visible {
return D{}
}
if a.opt.Logs == nil {
return D{}
}
entries := o.tail(a.opt.Logs)
if o.copyBtn.Clicked(gtx) {
a.copyToClipboard(gtx, a.opt.Logs.ExportText(core.ExportOptions{
Header: a.diagnosticHeader(),
Query: core.LogQuery{MinLevel: slog.LevelDebug},
}), a.th.T(KCopied))
}
if o.closeBtn.Clicked(gtx) {
o.visible = false
}
if duringSplash {
return layout.S.Layout(gtx, func(gtx C) D {
return layout.Inset{
Left: SpaceXL, Right: SpaceXL, Bottom: SpaceXL,
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return o.panel(a, gtx, entries, dockedLogHeight)
})
})
}
return layout.SE.Layout(gtx, func(gtx C) D {
return layout.Inset{Right: SpaceXL, Bottom: SpaceXL}.Layout(gtx, func(gtx C) D {
w := min(gtx.Constraints.Max.X, gtx.Dp(520))
gtx.Constraints.Max.X = w
gtx.Constraints.Min.X = w
return o.panel(a, gtx, entries, unit.Dp(300))
})
})
}
func (o *logOverlay) panel(a *App, gtx C, entries []core.LogEntry, height unit.Dp) D {
th := a.th
h := gtx.Dp(height)
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
glassPanel(th, gtx, gtx.Constraints.Min, float32(gtx.Dp(RadiusMD)))
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Top: SpaceMD, Bottom: SpaceMD, Left: SpaceLG, Right: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return o.header(a, gtx, len(entries)) }),
VGap(SpaceSM),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.Y = h
gtx.Constraints.Max.Y = h
return o.body(a, gtx, entries)
}),
)
})
}),
)
}
func (o *logOverlay) header(a *App, gtx C, n int) D {
th := a.th
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, LevelInfo, false)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
l := th.Text(SizeCaption, th.P.TextSec, th.T(KSplashLogHint))
l.Font.Weight = font.Medium
return OneLine(l).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, &o.copyBtn, IconCopy, LevelNeutral)
}),
layout.Rigid(func(gtx C) D {
if o.docked {
return D{}
}
return th.IconButton(gtx, &o.closeBtn, IconClose, LevelNeutral)
}),
)
}
func (o *logOverlay) body(a *App, gtx C, entries []core.LogEntry) D {
th := a.th
if len(entries) == 0 {
return layout.Center.Layout(gtx, th.Caption(th.T(KLoading)).Layout)
}
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
return o.list.Layout(gtx, len(entries), func(gtx C, i int) D {
return o.line(th, gtx, entries[i])
})
}
// line renders one compact log record: time, level, message, and the most
// useful attributes folded into a single trailing run so the column stays
// narrow.
func (o *logOverlay) line(th *Theme, gtx C, e core.LogEntry) D {
lvlCol := th.P.TextDim
switch {
case e.Level >= slog.LevelError:
lvlCol = th.P.Fail
case e.Level >= slog.LevelWarn:
lvlCol = th.P.Warn
case e.Level >= slog.LevelInfo:
lvlCol = th.P.Info
}
msgCol := th.P.TextSec
if e.Level >= slog.LevelWarn {
msgCol = th.P.TextPri
}
return layout.Inset{Top: 1, Bottom: 1}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.MonoLabel(SizeCaption, WithAlpha(th.P.TextDim, 0.85),
e.Time.Format("15:04:05")).Layout(gtx)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(26)
return th.MonoLabel(SizeCaption, lvlCol, core.LevelLabel(e.Level)).Layout(gtx)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
l := th.MonoLabel(SizeCaption, msgCol, overlayLineText(e))
l.MaxLines = 2
return l.Layout(gtx)
}),
)
})
}
// overlayLineText folds a record's attributes onto one line, dropping the
// "from" attribute because the subsystem is already implied by the message.
func overlayLineText(e core.LogEntry) string {
var b strings.Builder
b.WriteString(e.Msg)
for _, a := range e.Attrs {
if a.Key == "from" {
continue
}
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
b.WriteString(Truncate(core.Redact(a.Value), 64))
}
return b.String()
}
// diagnosticHeader is the metadata block prepended to any exported log bundle,
// so a paste is self-describing without the reporter having to explain their
// setup.
func (a *App) diagnosticHeader() string {
st := a.state()
var b strings.Builder
b.WriteString("# tslink diagnostic bundle\n")
b.WriteString("# version: " + a.opt.Version + "\n")
b.WriteString("# os/arch: " + runtimeInfo() + "\n")
if a.opt.ConfigURL != "" {
b.WriteString("# config: (url)\n")
} else if a.opt.ConfigPath != "" {
b.WriteString("# config: " + a.opt.ConfigPath + "\n")
}
b.WriteString("# phase: " + st.Phase.String() + "\n")
b.WriteString("# restarts: " + itoa(st.Restarts) + "\n")
if !st.ReadyAt.IsZero() {
b.WriteString("# uptime: " + FormatDuration(timeSince(st.ReadyAt)) + "\n")
}
if st.Peers != nil {
snap := st.Peers.Snapshot()
b.WriteString("# tailnet: " + snap.TailnetName + "\n")
b.WriteString("# peers: " + itoa(len(snap.Peers)) + "\n")
}
// reportText takes the diag page's lock; reading a.diag.report directly
// would race the background diagnostic goroutine.
if a.diag != nil {
if txt := a.diag.reportText(); txt != "" {
b.WriteString("#\n")
b.WriteString(txt)
}
}
return b.String()
}
+988
View File
@@ -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)
}),
)
}),
)
})
}),
)
}
+190
View File
@@ -0,0 +1,190 @@
package gui
import (
"time"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
type lanPage struct {
list widget.List
copy map[string]*widget.Clickable
}
func newLanPage() *lanPage {
p := &lanPage{copy: make(map[string]*widget.Clickable)}
p.list.Axis = layout.Vertical
return p
}
func (p *lanPage) copyBtn(key string) *widget.Clickable {
c, ok := p.copy[key]
if !ok {
c = &widget.Clickable{}
p.copy[key] = c
}
return c
}
func (p *lanPage) Layout(a *App, gtx C, st core.State) D {
th := a.th
if st.Lan == nil {
return th.EmptyState(gtx, IconBroadcast, th.T(KLanEmpty), th.T(KLoading))
}
servers := st.Lan.Servers()
scanErr := st.Lan.Err()
var advertised []core.LanEntry
if st.Config != nil {
advertised = core.LanEntriesFromRules(st.Config.Connect)
}
items := []layout.Widget{
func(gtx C) D { return p.summaryCard(a, gtx, servers, advertised, scanErr) },
}
if len(servers) == 0 {
items = append(items, func(gtx C) D {
hint := th.T(KLanSubtitle)
if scanErr != "" {
hint = scanErr
}
return th.EmptyState(gtx, IconServer, th.T(KLanEmpty), hint)
})
}
for _, s := range servers {
items = append(items, func(gtx C) D { return p.serverCard(a, gtx, s) })
}
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])
})
}
// summaryCard states what the scanner is doing and, crucially, whether the
// advertisements tslink itself emits are being heard back. A rule that is
// configured but not audible means the tunnel or the multicast path is broken,
// and that is the single most useful thing this page can tell someone.
func (p *lanPage) summaryCard(a *App, gtx C, servers []core.LanServer, advertised []core.LanEntry, scanErr string) D {
th := a.th
selfHeard := 0
for _, s := range servers {
if s.IsSelf {
selfHeard++
}
}
missing := len(advertised) - selfHeard
if missing < 0 {
missing = 0
}
card := th.Card()
card.Title = th.T(KLanTitle)
card.Subtitle = th.T(KLanSubtitle)
card.Trailing = func(gtx C) D {
level, label := LevelOK, th.T(KLanListening)
if scanErr != "" {
level, label = LevelFail, th.T(KLanBindError)
}
return th.Chip(gtx, ChipStyle{Text: label, Level: level, Dot: true})
}
rows := []KV{
{Key: th.T(KOvLanServers), Value: itoa(len(servers))},
{Key: th.T(KLanSelf), Value: itoa(selfHeard) + " / " + itoa(len(advertised)),
Hint: th.T(KLanSelfHint),
Level: selfLevel(len(advertised), selfHeard)},
}
if scanErr != "" {
rows = append(rows, KV{Key: th.T(KError), Value: scanErr, Level: LevelFail})
}
return card.Layout(th, gtx, func(gtx C) D {
return th.KVList(gtx, rows)
})
}
func selfLevel(advertised, heard int) StatusLevel {
switch {
case advertised == 0:
return LevelNeutral
case heard >= advertised:
return LevelOK
case heard == 0:
return LevelFail
default:
return LevelWarn
}
}
func (p *lanPage) serverCard(a *App, gtx C, s core.LanServer) D {
th := a.th
addr := s.Addr.String() + ":" + itoa(s.Port)
btn := p.copyBtn(addr)
if btn.Clicked(gtx) {
a.copyToClipboard(gtx, addr, "")
}
stale := time.Since(s.LastSeen) > 8*time.Second
level := LevelOK
if stale {
level = LevelWarn
}
card := th.Card()
card.Pad = SpaceMD
if s.IsSelf {
accent := th.P.Accent
card.Accent = &accent
}
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconServer(gtx, gtx.Dp(18), th.StatusColor(level))
}),
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(OneLine(th.Body(orDash(s.Motd))).Layout),
layout.Rigid(func(gtx C) D {
if !s.IsSelf {
return D{}
}
return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D {
return th.Chip(gtx, ChipStyle{
Text: th.T(KLanSelf),
Level: LevelInfo,
})
})
}),
)
}),
layout.Rigid(func(gtx C) D {
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, addr)).Layout(gtx)
}),
)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.Text(SizeCaption, th.StatusColor(level),
RelTime(th, s.LastSeen, time.Now())).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return th.Caption(itoa(s.Count) + " " + th.T(KLanPackets)).Layout(gtx)
}),
)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, btn, IconCopy, LevelNeutral)
}),
)
})
}
+496
View File
@@ -0,0 +1,496 @@
package gui
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
"tslink/netdiag"
)
type logsPage struct {
app *App
list widget.List
search widget.Editor
level widget.Enum
source widget.Enum
follow widget.Bool
redact widget.Bool
copyBtn widget.Clickable
saveBtn widget.Clickable
uploadBtn widget.Clickable
urlCopyBtn widget.Clickable
// mu guards the upload/save result fields, written from a goroutine.
mu sync.Mutex
uploading bool
uploadURL string
uploadTarget string
uploadErr string
savedPath string
// Cached filter result. Re-running the query over the whole ring on every
// frame is wasted work: it can only change when a record is appended or
// the query itself changes.
cached []core.LogEntry
cachedSeq uint64
cachedLen int
cachedQ core.LogQuery
}
// entries returns the filtered records, recomputing only when the buffer or
// the query moved.
func (p *logsPage) entries(buf *core.LogBuffer) []core.LogEntry {
q := p.query()
seq, n := buf.LastSeq(), buf.Len()
if p.cached != nil && seq == p.cachedSeq && n == p.cachedLen && q == p.cachedQ {
return p.cached
}
p.cached = buf.Filter(q)
p.cachedSeq, p.cachedLen, p.cachedQ = seq, n, q
return p.cached
}
func newLogsPage(a *App) *logsPage {
p := &logsPage{app: a}
p.list.Axis = layout.Vertical
p.search.SingleLine = true
p.level.Value = "all"
p.source.Value = "all"
p.follow.Value = true
p.redact.Value = true
return p
}
func (p *logsPage) minLevel() slog.Level {
switch p.level.Value {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelDebug - 4 // below everything
}
}
func (p *logsPage) query() core.LogQuery {
q := core.LogQuery{
MinLevel: p.minLevel(),
Text: strings.TrimSpace(p.search.Text()),
}
if p.source.Value != "all" {
q.Source = p.source.Value
}
return q
}
func (p *logsPage) Layout(a *App, gtx C, st core.State) D {
th := a.th
if a.opt.Logs == nil {
return th.EmptyState(gtx, IconList, th.T(KLogsEmpty), "")
}
buf := a.opt.Logs
p.handleActions(a, gtx, buf)
p.list.ScrollToEnd = p.follow.Value
entries := p.entries(buf)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
return p.toolbar(a, gtx, buf, len(entries))
})
}),
layout.Flexed(1, func(gtx C) D {
return p.logList(a, gtx, entries)
}),
)
}
func (p *logsPage) handleActions(a *App, gtx C, buf *core.LogBuffer) {
th := a.th
export := func() string {
return buf.ExportText(core.ExportOptions{
Query: p.query(),
NoRedact: !p.redact.Value,
Header: a.diagnosticHeader(),
})
}
if p.copyBtn.Clicked(gtx) {
a.copyToClipboard(gtx, export(), th.T(KCopied))
}
if p.saveBtn.Clicked(gtx) {
path, err := saveLogFile(export())
p.mu.Lock()
if err != nil {
p.savedPath = ""
p.uploadErr = err.Error()
} else {
p.savedPath = path
p.uploadErr = ""
}
p.mu.Unlock()
if err != nil {
a.notify(th.T(KError)+": "+err.Error(), LevelFail)
} else {
a.notify(path, LevelOK)
}
}
if p.uploadBtn.Clicked(gtx) {
p.startUpload(a, export())
}
if p.urlCopyBtn.Clicked(gtx) {
p.mu.Lock()
url := p.uploadURL
p.mu.Unlock()
if url != "" {
a.copyToClipboard(gtx, url, th.T(KCopied))
}
}
}
// startUpload publishes the bundle to a public paste service.
//
// This sends the user's logs off the machine, so the redaction toggle is on by
// default and the button label says "upload and share" rather than something
// vaguer: nobody should be surprised about what just left their computer.
func (p *logsPage) startUpload(a *App, text string) {
p.mu.Lock()
if p.uploading {
p.mu.Unlock()
return
}
p.uploading = true
p.uploadURL = ""
p.uploadErr = ""
p.mu.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
res, err := netdiag.Upload(ctx, "", text, a.logger.With("from", "paste"))
p.mu.Lock()
p.uploading = false
if err != nil {
p.uploadErr = err.Error()
} else {
p.uploadURL = res.URL
p.uploadTarget = res.Target
}
p.mu.Unlock()
if err != nil {
a.notify(a.th.T(KLogsUploadFail)+": "+Truncate(err.Error(), 80), LevelFail)
} else {
a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK)
}
if a.win != nil {
a.win.Invalidate()
}
}()
}
// saveLogFile writes the bundle next to the user's home directory. There is no
// native file picker without pulling in another dependency, so the app picks a
// predictable path and reports it rather than silently doing nothing.
func saveLogFile(content string) (string, error) {
dir, err := os.UserHomeDir()
if err != nil || dir == "" {
dir = "."
}
name := "tslink-log-" + time.Now().Format("20060102-150405") + ".txt"
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", err
}
return path, nil
}
func (p *logsPage) toolbar(a *App, gtx C, buf *core.LogBuffer, shown int) D {
th := a.th
counts := buf.Counts()
total := buf.Len()
card := th.Card()
card.Pad = SpaceMD
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
// Row 1: search + level filter.
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return p.searchField(a, gtx)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
return th.Segmented(gtx, &p.level, []SegmentOption{
{Key: "all", Label: th.T(KLogsAll), Count: total},
{Key: "debug", Label: "DBG", Count: counts[slog.LevelDebug]},
{Key: "info", Label: "INF", Count: counts[slog.LevelInfo]},
{Key: "warn", Label: "WRN", Count: counts[slog.LevelWarn], Level: LevelWarn},
{Key: "error", Label: "ERR", Count: counts[slog.LevelError], Level: LevelFail},
})
}),
)
}),
// Row 2: source filter.
layout.Rigid(func(gtx C) D {
sources := buf.Sources()
if len(sources) == 0 {
return D{}
}
if len(sources) > 6 {
sources = sources[:6]
}
opts := make([]SegmentOption, 0, len(sources)+1)
opts = append(opts, SegmentOption{Key: "all", Label: th.T(KLogsAll), Count: -1})
for _, s := range sources {
opts = append(opts, SegmentOption{Key: s, Label: s, Count: -1})
}
return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D {
return th.Segmented(gtx, &p.source, opts)
})
}),
// Row 3: toggles + actions.
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.follow, th.T(KLogsFollow))
}),
HGap(SpaceLG),
layout.Rigid(func(gtx C) D {
return th.Toggle(gtx, &p.redact, th.T(KLogsRedact))
}),
layout.Flexed(1, func(gtx C) D {
return layout.E.Layout(gtx, func(gtx C) D {
return p.actions(a, gtx)
})
}),
)
})
}),
// Row 4: counts + upload result.
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D {
return p.statusLine(a, gtx, buf, shown, total)
})
}),
)
})
}
func (p *logsPage) searchField(a *App, gtx C) D {
th := a.th
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.BgElevated)
StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border)
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Top: 7, Bottom: 7, Left: SpaceMD, Right: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconSearch(gtx, gtx.Dp(14), th.P.TextDim)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
ed := material.Editor(th.Theme, &p.search, th.T(KLogsSearch))
ed.TextSize = SizeBody
ed.Color = th.P.TextPri
ed.HintColor = th.P.TextDim
return ed.Layout(gtx)
}),
)
})
}),
)
}
func (p *logsPage) actions(a *App, gtx C) D {
th := a.th
p.mu.Lock()
uploading := p.uploading
p.mu.Unlock()
uploadLabel := th.T(KLogsUpload)
if uploading {
uploadLabel = th.T(KLogsUploading)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.Button(gtx, &p.copyBtn, ButtonStyle{
Kind: ButtonGhost, Text: th.T(KLogsCopyAll), Icon: IconCopy,
})
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.Button(gtx, &p.saveBtn, ButtonStyle{
Kind: ButtonGhost, Text: th.T(KLogsSaveFile), Icon: IconSave,
})
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.Button(gtx, &p.uploadBtn, ButtonStyle{
Kind: ButtonSubtle,
Text: uploadLabel,
Icon: IconUpload,
Disabled: uploading,
})
}),
)
}
func (p *logsPage) statusLine(a *App, gtx C, buf *core.LogBuffer, shown, total int) D {
th := a.th
p.mu.Lock()
url, target, upErr, saved := p.uploadURL, p.uploadTarget, p.uploadErr, p.savedPath
p.mu.Unlock()
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
txt := th.T(KLogsShown) + " " + itoa(shown) + " / " + itoa(total)
if d := buf.Dropped(); d > 0 {
txt += " · " + itoa(int(d)) + " " + th.T(KLogsDropped)
}
return th.Caption(txt).Layout(gtx)
}),
layout.Flexed(1, func(gtx C) D {
return layout.E.Layout(gtx, func(gtx C) D {
switch {
case url != "":
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return OneLine(th.MonoLabel(SizeCaption, th.P.OK, url)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if target == "" {
return D{}
}
return layout.Inset{Left: 6}.Layout(gtx,
th.Caption("("+target+")").Layout)
}),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, &p.urlCopyBtn, IconCopy, LevelOK)
}),
)
case upErr != "":
return OneLine(th.Text(SizeCaption, th.P.Fail, Truncate(upErr, 90))).Layout(gtx)
case saved != "":
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, saved)).Layout(gtx)
default:
return OneLine(th.Caption(th.T(KLogsRedactHint))).Layout(gtx)
}
})
}),
)
}
func (p *logsPage) logList(a *App, gtx C, entries []core.LogEntry) D {
th := a.th
card := th.Card()
card.Pad = SpaceSM
return card.Layout(th, gtx, func(gtx C) D {
if len(entries) == 0 {
return th.EmptyState(gtx, IconSearch, th.T(KLogsEmpty), "")
}
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
return material.List(th.Theme, &p.list).Layout(gtx, len(entries), func(gtx C, i int) D {
return p.logRow(th, gtx, entries[i])
})
})
}
func (p *logsPage) logRow(th *Theme, gtx C, e core.LogEntry) D {
lvlCol := th.P.TextDim
switch {
case e.Level >= slog.LevelError:
lvlCol = th.P.Fail
case e.Level >= slog.LevelWarn:
lvlCol = th.P.Warn
case e.Level >= slog.LevelInfo:
lvlCol = th.P.Info
}
msgCol := th.P.TextSec
if e.Level >= slog.LevelWarn {
msgCol = th.P.TextPri
}
msg := e.Msg
attrs := make([]string, 0, len(e.Attrs))
for _, at := range e.Attrs {
if at.Key == "from" {
continue
}
attrs = append(attrs, at.Key+"="+core.Redact(at.Value))
}
return layout.Inset{Top: 2, Bottom: 2, Left: SpaceSM, Right: SpaceSM}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Start}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.MonoLabel(SizeMono, WithAlpha(th.P.TextDim, 0.9),
e.Time.Format("15:04:05.000")).Layout(gtx)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(28)
return th.MonoLabel(SizeMono, lvlCol, core.LevelLabel(e.Level)).Layout(gtx)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
if e.Source == "" {
return D{}
}
gtx.Constraints.Max.X = gtx.Dp(96)
l := th.MonoLabel(SizeMono, WithAlpha(th.P.Info, 0.85), e.Source)
l.MaxLines = 1
l.Alignment = text.End
return l.Layout(gtx)
}),
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.MonoLabel(SizeMono, msgCol, core.Redact(msg))
l.MaxLines = 3
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if len(attrs) == 0 {
return D{}
}
l := th.MonoLabel(SizeMono, WithAlpha(th.P.TextDim, 0.95),
strings.Join(attrs, " "))
l.MaxLines = 2
return l.Layout(gtx)
}),
)
}),
)
})
}
+365
View File
@@ -0,0 +1,365 @@
package gui
import (
"strings"
"time"
"gioui.org/font"
"gioui.org/layout"
"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
}
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()
}
var lanServers []core.LanServer
if st.Lan != nil {
lanServers = st.Lan.Servers()
}
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, lanServers) },
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.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)
}
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if hint == "" {
return D{}
}
return OneLine(th.Caption(hint)).Layout(gtx)
}),
)
})
}
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, lan []core.LanServer) 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)
}
}
selfLan := 0
for _, s := range lan {
if s.IsSelf {
selfLan++
}
}
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(len(lan)),
th.T(KOvLanServers),
itoa(selfLan)+" "+th.T(KLanSelf),
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.Status)), 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),
})
}),
)
})
}
+479
View File
@@ -0,0 +1,479 @@
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
}
+144
View File
@@ -0,0 +1,144 @@
package gui
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
type settingsPage struct {
app *App
list widget.List
theme widget.Enum
lang widget.Enum
restart widget.Clickable
}
func newSettingsPage(a *App) *settingsPage {
p := &settingsPage{app: a}
p.list.Axis = layout.Vertical
p.theme.Value = "dark"
if !a.th.Dark {
p.theme.Value = "light"
}
p.lang.Value = "zh"
if a.th.Lang == LangEN {
p.lang.Value = "en"
}
return p
}
func (p *settingsPage) Layout(a *App, gtx C, st core.State) D {
th := a.th
if p.theme.Update(gtx) {
th.SetDark(p.theme.Value == "dark")
}
if p.lang.Update(gtx) {
if p.lang.Value == "en" {
th.Lang = LangEN
} else {
th.Lang = LangZH
}
}
if p.restart.Clicked(gtx) && a.opt.Supervisor != nil {
a.opt.Supervisor.Restart()
a.notify(th.T(KStateRetrying), LevelInfo)
}
items := []layout.Widget{
func(gtx C) D { return p.appearanceCard(a, gtx) },
func(gtx C) D { return p.aboutCard(a, gtx, st) },
}
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])
})
}
func (p *settingsPage) appearanceCard(a *App, gtx C) D {
th := a.th
card := th.Card()
card.Title = th.T(KNavSettings)
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return p.settingRow(a, gtx, th.T(KSetTheme), "", func(gtx C) D {
return th.Segmented(gtx, &p.theme, []SegmentOption{
{Key: "dark", Label: th.T(KSetThemeDark), Count: -1},
{Key: "light", Label: th.T(KSetThemeLight), Count: -1},
})
})
}),
layout.Rigid(th.Divider),
layout.Rigid(func(gtx C) D {
hint := ""
if !th.HasCJK {
hint = th.T(KSetFontMissing)
}
return p.settingRow(a, gtx, th.T(KSetLanguage), hint, func(gtx C) D {
return th.Segmented(gtx, &p.lang, []SegmentOption{
{Key: "zh", Label: "中文", Count: -1},
{Key: "en", Label: "English", Count: -1},
})
})
}),
)
})
}
func (p *settingsPage) settingRow(a *App, gtx C, label, hint string, control layout.Widget) D {
th := a.th
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, 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(th.Body(label).Layout),
layout.Rigid(func(gtx C) D {
if hint == "" {
return D{}
}
return th.Caption(hint).Layout(gtx)
}),
)
}),
layout.Rigid(control),
)
})
}
func (p *settingsPage) aboutCard(a *App, gtx C, st core.State) D {
th := a.th
card := th.Card()
card.Title = th.T(KSetAbout)
card.Trailing = func(gtx C) D {
return th.Button(gtx, &p.restart, ButtonStyle{
Kind: ButtonSubtle, Text: th.T(KRetry), Icon: IconRefresh,
})
}
cfgPath := a.opt.ConfigPath
if a.opt.ConfigURL != "" {
cfgPath = a.opt.ConfigURL
}
fontPath := a.fonts.CJKPath
if fontPath == "" {
fontPath = th.T(KNone)
}
return card.Layout(th, gtx, func(gtx C) D {
rows := []KV{
{Key: th.T(KSetVersion), Value: orDash(a.opt.Version), Mono: true},
{Key: "Runtime", Value: runtimeInfo(), Mono: true},
{Key: th.T(KSetConfigPath), Value: orDash(cfgPath), Mono: true},
{Key: th.T(KSetFont), Value: fontPath, Mono: true},
{Key: th.T(KStateRunning), Value: st.Phase.String()},
}
if st.Err != "" {
rows = append(rows, KV{Key: th.T(KError), Value: st.Err, Level: LevelFail})
}
return th.KVList(gtx, rows)
})
}
+308
View File
@@ -0,0 +1,308 @@
package gui
import (
"image"
"log/slog"
"net/netip"
"testing"
"time"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"tslink/core"
"tslink/netdiag"
)
// These tests lay out every page without a GPU or a window.
//
// Layout is where a Gio UI actually breaks: a negative constraint, an
// unbounded flex child or a nil dereference in a rarely-taken branch panics at
// draw time, and there is no compiler check for any of it. Measurement runs
// the full flex/stack/text-shaping path, so exercising it catches those
// without needing a display — which also means it runs in CI.
func testTheme(t *testing.T) *Theme {
t.Helper()
// Skip system font discovery: CI images have no CJK font and the walk
// would make the test depend on the host's font configuration.
fonts := &FontSet{Collection: goCollection(), UI: "Go", Mono: "Go Mono"}
th := NewTheme(fonts, true)
th.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(fonts.Collection))
return th
}
// newTestContext builds a layout context backed by a real input router, so
// widgets that register event handlers behave as they do on screen.
func newTestContext(size image.Point) (layout.Context, *input.Router) {
var r input.Router
gtx := layout.Context{
Ops: new(op.Ops),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
Now: time.Now(),
Source: r.Source(),
}
return gtx, &r
}
// testApp builds an App with no supervisor, which is the state the GUI is in
// before the service comes up.
func testApp(t *testing.T) *App {
t.Helper()
logs := core.NewLogBuffer(256)
logger := slog.New(logs.Handler(nil))
for i := 0; i < 40; i++ {
logger.Info("synthetic log line", "i", i, "from", "test")
}
logger.Error("synthetic failure", "err", "boom", "from", "test")
a := New(Options{
Version: "test",
ConfigPath: "config.toml",
Logs: logs,
Logger: logger,
StartDark: true,
})
a.th = testTheme(t)
return a
}
// readyState fabricates a running service with a peer, a LAN server and a
// diagnostic report, so the populated branches of every page get exercised
// rather than just the empty states.
func readyState(t *testing.T) core.State {
t.Helper()
logger := slog.New(slog.DiscardHandler)
cfg := &core.Config{
Core: core.Core{Hostname: "test"},
Connect: map[string][]core.ConnectRule{
"survival": {{Protocol: "minecraft", LocalPort: 25565, DstAddr: "peer:25565"}},
},
Forward: map[string][]core.ForwardRule{
"web": {{Protocol: "tcp", TailscalePort: 80, LocalAddr: "127.0.0.1:8080"}},
},
}
return core.State{
Phase: core.PhaseReady,
Steps: nil,
StartedAt: time.Now().Add(-time.Hour),
ReadyAt: time.Now().Add(-time.Hour),
Config: cfg,
Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}),
Lan: core.NewLanScanner(logger),
}
}
func TestPagesLayout(t *testing.T) {
sizes := []image.Point{
{X: 1200, Y: 800}, // roomy
{X: 880, Y: 560}, // the declared minimum window
{X: 640, Y: 400}, // below minimum: compact rail, everything must still fit
}
pages := []pageID{pageOverview, pagePeers, pageLan, pageDiag, pageLogs, pageSettings}
for _, size := range sizes {
for _, page := range pages {
a := testApp(t)
a.current = page
st := readyState(t)
gtx, _ := newTestContext(size)
// Two frames: the first registers widget state, the second takes
// the paths that depend on it (hover, list position, caches).
for i := 0; i < 2; i++ {
a.shell(gtx, st)
}
}
}
}
func TestSplashLayout(t *testing.T) {
phases := []core.Phase{
core.PhaseIdle, core.PhaseStarting, core.PhaseRetrying,
core.PhaseError, core.PhaseStopped,
}
for _, phase := range phases {
for _, size := range []image.Point{{X: 1200, Y: 800}, {X: 880, Y: 560}, {X: 700, Y: 380}} {
a := testApp(t)
st := core.State{
Phase: phase,
Steps: splashTestSteps(),
StartedAt: time.Now().Add(-10 * time.Second),
Err: "core.auth_key is required",
}
gtx, _ := newTestContext(size)
a.splash.Layout(a, gtx, st)
// The overlay is forced visible during the splash; it must lay out
// on top without depending on the splash having run.
a.overlay.Layout(a, gtx, true)
}
}
}
func splashTestSteps() []core.BootStep {
now := time.Now()
return []core.BootStep{
{Key: core.StepKeyConfig, State: core.StepDone, Started: now.Add(-3 * time.Second), Finished: now.Add(-2 * time.Second)},
{Key: core.StepKeyTsnet, State: core.StepRunning, Started: now.Add(-2 * time.Second)},
{Key: core.StepKeyRules, State: core.StepPending},
{Key: core.StepKeyServices, State: core.StepFailed, Err: "listen: address already in use"},
{Key: core.StepKeyMonitors, State: core.StepSkipped},
{Key: core.StepKeyReady, State: core.StepPending},
}
}
// TestDiagPageWithReport renders every diagnostic section with a populated
// report, including the awkward cases: tri-state unknowns, divergent egress,
// and a failed probe row.
func TestDiagPageWithReport(t *testing.T) {
a := testApp(t)
a.current = pageDiag
yes := true
rep := &netdiag.Report{
StartedAt: time.Now().Add(-20 * time.Second),
Duration: 18 * time.Second,
Status: netdiag.StatusWarn,
Headline: "对称型 NAT:与同样受限的对端难以打洞",
Interfaces: netdiag.InterfaceReport{
Status: netdiag.StatusOK,
Summary: "2 个接口 / 3 个地址",
DefaultV4Src: netip.MustParseAddr("192.168.1.23"),
Addrs: []netdiag.LocalAddr{
{Iface: "eth0", Addr: netip.MustParseAddr("192.168.1.23"), Kind: netdiag.AddrPrivateV4, Up: true, MTU: 1500, IsDefaultSrc: true},
{Iface: "tailscale0", Addr: netip.MustParseAddr("100.101.102.103"), Kind: netdiag.AddrTailscale, Up: true, MTU: 1280},
},
},
UDP: netdiag.UDPReport{
Status: netdiag.StatusWarn, Summary: "UDP 可用", V4OK: true,
CNReachable: 3, CNTotal: 5, IntlReachabl: 1, IntlTotal: 7,
BlockedPorts: []int{19302},
Probes: []netdiag.UDPProbe{
{Target: "stun.miwifi.com:3478", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Target: "stun.l.google.com:19302", Region: netdiag.RegionIntl, Err: "i/o timeout"},
},
},
NAT: netdiag.NATReport{
Status: netdiag.StatusFail, Type: netdiag.NATSymmetric,
Mapping: netdiag.BehaviorAddressAndPortDependent, Filtering: netdiag.BehaviorUnknown,
Hairpin: nil, PortPreserving: &yes,
MappedAddrs: []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:1"), netip.MustParseAddrPort("1.2.3.4:2")},
Notes: []string{"没有服务器支持 CHANGE-REQUEST"},
Results: []netdiag.STUNResult{
{Server: "stun.qq.com:3478", Name: "腾讯", Region: netdiag.RegionCN, OK: true, RTT: 9 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:1")},
{Server: "stun.cloudflare.com:3478", Region: netdiag.RegionIntl, Err: "no response"},
},
},
PortMap: netdiag.PortMapReport{
Status: netdiag.StatusWarn, Gateway: netip.MustParseAddr("192.168.1.1"),
UPnP: netdiag.ServiceProbe{Available: true, Detail: "Archer AX73 (TP-Link)", ExternalIP: netip.MustParseAddr("1.2.3.4")},
NATPMP: netdiag.ServiceProbe{Err: "timeout"},
PCP: netdiag.ServiceProbe{Err: "timeout"},
},
Overseas: netdiag.OverseasReport{
Status: netdiag.StatusWarn, Summary: "境外不可达",
Probes: []netdiag.ReachProbe{
{Name: "cf", URL: "https://cp.cloudflare.com/generate_204", Region: netdiag.RegionIntl, Network: "tcp4", Err: "timeout"},
{Name: "baidu", URL: "https://www.baidu.com", Region: netdiag.RegionCN, OK: true, StatusCode: 200, RTT: 30 * time.Millisecond},
},
},
Egress: netdiag.EgressReport{
Status: netdiag.StatusWarn, Divergent: true, Summary: "出口 IP 不一致",
UniqueIPs: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")},
Observations: []netdiag.EgressObservation{
{Method: netdiag.MethodSTUN, Source: "stun.qq.com:3478", Region: netdiag.RegionCN, IP: netip.MustParseAddr("1.2.3.4")},
{Method: netdiag.MethodHTTPProxy, Source: "https://api.ipify.org", Region: netdiag.RegionIntl, IP: netip.MustParseAddr("5.6.7.8")},
{Method: netdiag.MethodHTTPv6, Source: "https://6.ipw.cn", Region: netdiag.RegionCN, Err: "no ipv6"},
},
Geo: []netdiag.GeoInfo{
{IP: netip.MustParseAddr("1.2.3.4"), Country: "CN", City: "Shanghai", ASN: "AS4134", Org: "Chinanet", Provider: "ipinfo.io"},
{IP: netip.MustParseAddr("5.6.7.8"), Err: "lookup failed"},
},
Countries: []string{"CN", "JP"},
},
Tailscale: netdiag.TailscaleReport{
Available: true, UDP: true, IPv4: true, Status: netdiag.StatusOK,
Summary: "首选 DERP tok", PreferredDERP: "tok",
MappingVariesByDestIP: &yes,
DERP: []netdiag.DERPLatency{
{RegionID: 1, RegionCode: "tok", Name: "Tokyo", Latency: 40 * time.Millisecond, Preferred: true},
{RegionID: 2, RegionCode: "sin", Name: "Singapore", Latency: 90 * time.Millisecond},
},
},
}
a.diag.report = rep
a.diag.lastRun = time.Now()
for _, size := range []image.Point{{X: 1200, Y: 800}, {X: 880, Y: 560}} {
gtx, _ := newTestContext(size)
st := readyState(t)
for i := 0; i < 2; i++ {
a.shell(gtx, st)
}
}
// The report must also render as shareable text without panicking.
if got := rep.Text(); got == "" {
t.Fatal("Report.Text returned empty")
}
}
// TestOverlayLayout covers the floating (non-docked) overlay, which has a
// different anchor and a close button the docked one hides.
func TestOverlayLayout(t *testing.T) {
a := testApp(t)
a.overlay.visible = true
gtx, _ := newTestContext(image.Pt(1000, 700))
a.overlay.Layout(a, gtx, false)
}
func TestFormatHelpers(t *testing.T) {
cases := []struct {
got, want string
}{
{FormatLatency(0), "—"},
{FormatLatency(1500 * time.Microsecond), "1.5 ms"},
{FormatLatency(42 * time.Millisecond), "42 ms"},
{FormatLatency(2500 * time.Millisecond), "2.5 s"},
{FormatBytes(0), "0 B"},
{FormatBytes(2048), "2 KiB"},
{FormatBytes(5 * 1024 * 1024), "5 MiB"},
{FormatDuration(90 * time.Second), "1m 30s"},
{FormatDuration(3 * time.Hour), "3h 0m"},
{Truncate("abcdef", 4), "abc…"},
{Truncate("ab", 4), "ab"},
}
for i, c := range cases {
if c.got != c.want {
t.Errorf("case %d: got %q want %q", i, c.got, c.want)
}
}
}
func TestTrFallsBackToEnglish(t *testing.T) {
if Tr(LangEN, KNavPeers) != "Peers" {
t.Errorf("english lookup failed")
}
if Tr(LangZH, KNavPeers) != "节点" {
t.Errorf("chinese lookup failed")
}
if Tr(LangZH, Key(-1)) != "?" {
t.Errorf("out-of-range key should not panic or return empty")
}
// Every key must resolve in both languages; a missing entry would render
// as a bare "?" in the UI.
for k := Key(0); k < kCount; k++ {
if Tr(LangEN, k) == "?" {
t.Errorf("key %d has no english string", k)
}
if Tr(LangZH, k) == "?" {
t.Errorf("key %d has no chinese string", k)
}
}
}
+297
View File
@@ -0,0 +1,297 @@
package gui
import (
"image"
"math"
"time"
"gioui.org/f32"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
// splashView is the loading screen. It covers the window until the service is
// up, which is also the window during which the CJK font is parsed and
// tailscale negotiates its first connection — both slow enough that showing a
// bare grey rectangle would read as a hang.
type splashView struct {
retry widget.Clickable
// list keeps the panel reachable on short windows. Without it the retry
// button — the one control on this screen — falls off the bottom edge once
// the checklist and an error message are both showing.
list widget.List
}
func newSplashView() *splashView {
s := &splashView{}
s.list.Axis = layout.Vertical
return s
}
// stepTitles maps supervisor step keys onto localised labels.
func stepTitle(th *Theme, key string) string {
switch key {
case core.StepKeyConfig:
return th.T(KStepConfig)
case core.StepKeyTsnet:
return th.T(KStepTsnet)
case core.StepKeyRules:
return th.T(KStepRules)
case core.StepKeyServices:
return th.T(KStepDiscovery)
case core.StepKeyMonitors:
return th.T(KStepMonitors)
case core.StepKeyReady:
return th.T(KStepReady)
default:
return key
}
}
func (s *splashView) Layout(a *App, gtx C, st core.State) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
if s.retry.Clicked(gtx) && a.opt.Supervisor != nil {
a.opt.Supervisor.Restart()
}
// The docked log sheet sits along the bottom edge, so the panel is centred
// in whatever is left above it. Reserving the space rather than stacking
// the two is the whole point: a screenshot taken mid-load has to show both
// the checklist and the log.
reserve := dockedReserve(gtx)
if maxReserve := gtx.Constraints.Max.Y / 2; reserve > maxReserve {
reserve = maxReserve
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
// A single-element list: centred when it fits, scrollable when the
// window is too short for the checklist plus an error message.
return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(460))
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{Top: SpaceLG, Bottom: SpaceLG}.Layout(gtx, func(gtx C) D {
return s.panel(a, gtx, st)
})
})
})
}),
layout.Rigid(func(gtx C) D { return D{Size: image.Pt(0, reserve)} }),
)
}
func (s *splashView) panel(a *App, gtx C, st core.State) D {
th := a.th
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return s.pulse(a, gtx, st)
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
l := th.Text(SizeDisplay, th.P.TextPri, "tslink")
l.Font.Weight = font.Bold
l.Alignment = text.Middle
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
l := th.Caption(th.T(KAppSubtitle))
l.Alignment = text.Middle
return l.Layout(gtx)
}),
VGap(SpaceLG),
layout.Rigid(func(gtx C) D {
return th.ProgressBar(gtx, st.Progress(), th.P.Accent)
}),
VGap(SpaceLG),
layout.Rigid(func(gtx C) D {
return s.checklist(a, gtx, st)
}),
layout.Rigid(func(gtx C) D {
return s.footer(a, gtx, st)
}),
)
}
// pulse draws concentric rings radiating from a solid core. Three rings offset
// in phase read as continuous motion without a spinning element, which suits a
// "connecting to a network" wait better than a rotating arc.
func (s *splashView) pulse(a *App, gtx C, st core.State) D {
th := a.th
size := gtx.Dp(64)
center := f32.Pt(float32(size)/2, float32(size)/2)
col := th.P.Accent
switch st.Phase {
case core.PhaseError:
col = th.P.Fail
case core.PhaseRetrying:
col = th.P.Warn
}
const period = 2400 * time.Millisecond
base := float32(gtx.Dp(14))
grow := float32(size)/2 - base
if st.Phase != core.PhaseError {
phase := float64(gtx.Now.UnixNano()%int64(period)) / float64(period)
for i := 0; i < 3; i++ {
p := math.Mod(phase+float64(i)/3, 1)
r := base + grow*float32(p)
// Ease the fade so rings vanish before they hit the edge.
alpha := float32(1-p) * 0.55
drawArc(gtx, center, r, float32(gtx.Dp(1.5)), 0, 2*math.Pi, WithAlpha(col, alpha))
}
// A 2.4s cycle does not need 25fps, and this is the one animation that
// can legitimately run for minutes while tailscale negotiates.
animateSlow(gtx)
}
// Solid core.
d := gtx.Dp(22)
off := op.Offset(image.Pt((size-d)/2, (size-d)/2)).Push(gtx.Ops)
Circle(gtx, d, col)
off.Pop()
return D{Size: image.Pt(size, size)}
}
func (s *splashView) checklist(a *App, gtx C, st core.State) D {
th := a.th
children := make([]layout.FlexChild, 0, len(st.Steps))
for _, step := range st.Steps {
children = append(children, layout.Rigid(func(gtx C) D {
return s.stepRow(a, gtx, step)
}))
}
card := th.Card()
card.Pad = SpaceMD
card.Bg = &th.P.BgElevated
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
th := a.th
var (
fg = th.P.TextDim
badge layout.Widget
)
switch step.State {
case core.StepRunning:
fg = th.P.TextPri
badge = func(gtx C) D { return th.Spinner(gtx, gtx.Dp(14), th.P.Accent) }
case core.StepDone:
fg = th.P.TextSec
badge = func(gtx C) D { return IconCheck(gtx, gtx.Dp(14), th.P.OK) }
case core.StepFailed:
fg = th.P.Fail
badge = func(gtx C) D { return IconWarn(gtx, gtx.Dp(14), th.P.Fail) }
case core.StepSkipped:
badge = func(gtx C) D { return Circle(gtx, gtx.Dp(6), th.P.TextDim) }
default:
badge = func(gtx C) D {
// An empty ring reads as "not started" without adding a colour.
drawArc(gtx, f32.Pt(7, 7), 5, 1, 0, 2*math.Pi, WithAlpha(th.P.TextDim, 0.5))
return D{Size: image.Pt(gtx.Dp(14), gtx.Dp(14))}
}
}
return layout.Inset{Top: 5, Bottom: 5}.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)
return layout.W.Layout(gtx, badge)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(OneLine(th.Text(SizeBody, fg, stepTitle(th, step.Key))).Layout),
layout.Rigid(func(gtx C) D {
if step.Err == "" {
return D{}
}
return OneLine(th.Text(SizeCaption, th.P.Fail, step.Err)).Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx C) D {
if step.State != core.StepDone || step.Elapsed() < 100*time.Millisecond {
return D{}
}
return th.MonoLabel(SizeCaption, th.P.TextDim,
FormatLatency(step.Elapsed())).Layout(gtx)
}),
)
})
}
func (s *splashView) footer(a *App, gtx C, st core.State) D {
th := a.th
return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D {
switch st.Phase {
case core.PhaseError:
// Error text and the retry button sit side by side: stacking them
// pushes the only control on this screen below the fold on a short
// window.
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
l := th.Text(SizeCaption, th.P.Fail, st.Err)
l.MaxLines = 4
return l.Layout(gtx)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = 0
return th.Button(gtx, &s.retry, ButtonStyle{
Kind: ButtonPrimary,
Text: th.T(KRetry),
Icon: IconRefresh,
})
}),
)
case core.PhaseRetrying:
msg := th.T(KSplashRetry)
if st.Err != "" {
msg = st.Err
}
l := th.Text(SizeCaption, th.P.Warn, msg)
l.Alignment = text.Middle
l.MaxLines = 3
return l.Layout(gtx)
default:
l := th.Caption(th.T(KSplashHint))
l.Alignment = text.Middle
return l.Layout(gtx)
}
})
}
// ---------------------------------------------------------------------------
// Shared: a translucent panel backdrop
// ---------------------------------------------------------------------------
// glassPanel fills the current bounds with a translucent surface plus border.
// It is what makes the log overlay readable over whatever is behind it while
// still showing that something is behind it.
func glassPanel(t *Theme, gtx C, size image.Point, radius float32) {
r := int(radius)
bg := t.P.BgElevated
bg.A = 0xE0
paint.FillShape(gtx.Ops, bg, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops))
spec := clip.UniformRRect(image.Rectangle{Max: size}, r).Path(gtx.Ops)
paint.FillShape(gtx.Ops, WithAlpha(t.P.BorderHi, 0.8),
clip.Stroke{Path: spec, Width: 1}.Op())
}
+268
View File
@@ -0,0 +1,268 @@
package gui
import (
"image/color"
"gioui.org/font"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget/material"
)
// Spacing scale. Every gap in the UI is one of these; ad-hoc values are what
// makes an interface feel noisy.
const (
SpaceXS unit.Dp = 4
SpaceSM unit.Dp = 8
SpaceMD unit.Dp = 12
SpaceLG unit.Dp = 16
SpaceXL unit.Dp = 24
Space2XL unit.Dp = 32
)
// Corner radii.
const (
RadiusSM unit.Dp = 6
RadiusMD unit.Dp = 10
RadiusLG unit.Dp = 14
RadiusPill unit.Dp = 999
)
// Type scale. Only five sizes exist so hierarchy stays legible when a panel is
// dense with numbers.
const (
SizeDisplay unit.Sp = 23
SizeTitle unit.Sp = 17
SizeSubtitle unit.Sp = 14
SizeBody unit.Sp = 13
SizeCaption unit.Sp = 11.5
SizeMono unit.Sp = 12
)
// Palette holds every colour the UI is allowed to use.
type Palette struct {
// Surfaces, from furthest back to nearest front.
Bg color.NRGBA
BgElevated color.NRGBA
Surface color.NRGBA
SurfaceHi color.NRGBA
Border color.NRGBA
BorderHi color.NRGBA
// Three text weights carry the whole information hierarchy: primary for
// values, secondary for labels, dim for metadata.
TextPri color.NRGBA
TextSec color.NRGBA
TextDim color.NRGBA
Accent color.NRGBA
AccentDim color.NRGBA
AccentFg color.NRGBA
OK color.NRGBA
Warn color.NRGBA
Fail color.NRGBA
Info color.NRGBA
// Series colours for the latency chart, in assignment order. They are
// distinguishable at 2px stroke width and stay distinct in both themes.
Series []color.NRGBA
// Scrim dims the app behind the loading overlay.
Scrim color.NRGBA
}
func rgb(v uint32) color.NRGBA {
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xFF}
}
// DarkPalette is the default. The app is a diagnostic tool that people leave
// open in the background, so it defaults to the low-glare theme.
func DarkPalette() Palette {
return Palette{
Bg: rgb(0x0E1116),
BgElevated: rgb(0x141922),
Surface: rgb(0x1A202B),
SurfaceHi: rgb(0x222A38),
Border: rgb(0x252E3B),
BorderHi: rgb(0x364153),
TextPri: rgb(0xE7EBF3),
TextSec: rgb(0x9AA4B8),
TextDim: rgb(0x69738A),
Accent: rgb(0x4C8DFF),
AccentDim: rgb(0x27447A),
AccentFg: rgb(0xFFFFFF),
OK: rgb(0x3DCE87),
Warn: rgb(0xF0A93B),
Fail: rgb(0xFF6B6B),
Info: rgb(0x8B9BFF),
Series: []color.NRGBA{
rgb(0x4C8DFF), rgb(0x2DD4BF), rgb(0xA78BFA), rgb(0xFBBF24),
rgb(0xF472B6), rgb(0xA3E635), rgb(0x38BDF8), rgb(0xFB923C),
},
Scrim: color.NRGBA{R: 0x08, G: 0x0A, B: 0x0E, A: 0xC4},
}
}
// LightPalette mirrors the dark one for people working in bright rooms.
func LightPalette() Palette {
return Palette{
Bg: rgb(0xF6F7F9),
BgElevated: rgb(0xFFFFFF),
Surface: rgb(0xFFFFFF),
SurfaceHi: rgb(0xF0F2F6),
Border: rgb(0xE3E7ED),
BorderHi: rgb(0xCFD5DE),
TextPri: rgb(0x111826),
TextSec: rgb(0x4A5568),
TextDim: rgb(0x818C9E),
Accent: rgb(0x2563EB),
AccentDim: rgb(0xBFD3FA),
AccentFg: rgb(0xFFFFFF),
OK: rgb(0x0F9D58),
Warn: rgb(0xC77700),
Fail: rgb(0xD93636),
Info: rgb(0x4F5DD1),
Series: []color.NRGBA{
rgb(0x2563EB), rgb(0x0D9488), rgb(0x7C3AED), rgb(0xD97706),
rgb(0xDB2777), rgb(0x65A30D), rgb(0x0284C7), rgb(0xEA580C),
},
Scrim: color.NRGBA{R: 0x1A, G: 0x1F, B: 0x28, A: 0xB8},
}
}
// Theme bundles the Gio material theme with this app's design tokens.
type Theme struct {
*material.Theme
P Palette
Dark bool
// Mono is the typeface used for addresses, ports and log lines, where
// column alignment matters more than typographic polish.
Mono font.Typeface
// HasCJK reports whether a font with Chinese coverage was found. When it
// is false the UI falls back to English labels rather than rendering
// tofu boxes.
HasCJK bool
// Lang selects the label set.
Lang Lang
}
// NewTheme builds a theme from a shaper and font collection produced by
// [LoadFonts].
func NewTheme(fonts *FontSet, dark bool) *Theme {
mt := material.NewTheme()
mt.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
mt.TextSize = SizeBody
mt.Face = fonts.UI
mt.FingerSize = 26
th := &Theme{
Theme: mt,
Dark: dark,
Mono: fonts.Mono,
HasCJK: fonts.HasCJK,
Lang: LangEN,
}
if fonts.HasCJK {
th.Lang = LangZH
}
th.SetDark(dark)
return th
}
// SetDark switches palettes and keeps the embedded material palette in sync so
// stock Gio widgets pick up the right colours too.
func (t *Theme) SetDark(dark bool) {
t.Dark = dark
if dark {
t.P = DarkPalette()
} else {
t.P = LightPalette()
}
t.Theme.Palette = material.Palette{
Bg: t.P.Bg,
Fg: t.P.TextPri,
ContrastBg: t.P.Accent,
ContrastFg: t.P.AccentFg,
}
}
// T looks up a localised string. It is a method on Theme so call sites stay
// short: th.T(K.Peers).
func (t *Theme) T(k Key) string { return Tr(t.Lang, k) }
// StatusColor maps a traffic-light verdict onto the palette.
func (t *Theme) StatusColor(s StatusLevel) color.NRGBA {
switch s {
case LevelOK:
return t.P.OK
case LevelWarn:
return t.P.Warn
case LevelFail:
return t.P.Fail
case LevelInfo:
return t.P.Info
default:
return t.P.TextDim
}
}
// StatusLevel is the UI-side severity, deliberately decoupled from
// netdiag.Status so widgets do not depend on the diagnostics package.
type StatusLevel int
const (
LevelNeutral StatusLevel = iota
LevelOK
LevelWarn
LevelFail
LevelInfo
)
// SeriesColor returns a stable chart colour for index i.
func (t *Theme) SeriesColor(i int) color.NRGBA {
if len(t.P.Series) == 0 {
return t.P.Accent
}
return t.P.Series[i%len(t.P.Series)]
}
// WithAlpha returns c with its alpha scaled by a (0..1).
func WithAlpha(c color.NRGBA, a float32) color.NRGBA {
if a < 0 {
a = 0
}
if a > 1 {
a = 1
}
c.A = uint8(float32(c.A) * a)
return c
}
// Mix blends a into b by t (0 returns a, 1 returns b).
func Mix(a, b color.NRGBA, t float32) color.NRGBA {
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
lerp := func(x, y uint8) uint8 { return uint8(float32(x) + (float32(y)-float32(x))*t) }
return color.NRGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: lerp(a.A, b.A)}
}
+20
View File
@@ -0,0 +1,20 @@
package gui
import (
"runtime"
"time"
)
// runtimeInfo describes the host, for diagnostic bundle headers.
func runtimeInfo() string {
return runtime.GOOS + "/" + runtime.GOARCH + " go" + runtime.Version()[2:]
}
// timeSince is time.Since, wrapped so tests can reason about it and so call
// sites in layout code read consistently.
func timeSince(t time.Time) time.Duration {
if t.IsZero() {
return 0
}
return time.Since(t)
}
+906
View File
@@ -0,0 +1,906 @@
package gui
import (
"image"
"image/color"
"math"
"strings"
"time"
"gioui.org/f32"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
// Short aliases, the conventional Gio shorthand.
type (
C = layout.Context
D = layout.Dimensions
)
// ---------------------------------------------------------------------------
// Text
// ---------------------------------------------------------------------------
// Text returns a label in the app's type scale.
func (t *Theme) Text(size unit.Sp, col color.NRGBA, txt string) material.LabelStyle {
l := material.Label(t.Theme, size, txt)
l.Color = col
return l
}
// Mono returns a monospaced label, used wherever columns of addresses, ports
// or timings need to line up.
func (t *Theme) MonoLabel(size unit.Sp, col color.NRGBA, txt string) material.LabelStyle {
l := t.Text(size, col, txt)
l.Font.Typeface = t.Mono
return l
}
// Title is the heading of a page.
func (t *Theme) Title(txt string) material.LabelStyle {
l := t.Text(SizeTitle, t.P.TextPri, txt)
l.Font.Weight = font.SemiBold
return l
}
// Display is the single largest text on a page, used for headline numbers.
func (t *Theme) Display(txt string) material.LabelStyle {
l := t.Text(SizeDisplay, t.P.TextPri, txt)
l.Font.Weight = font.SemiBold
return l
}
// Body is normal running text.
func (t *Theme) Body(txt string) material.LabelStyle {
return t.Text(SizeBody, t.P.TextPri, txt)
}
// Secondary is a de-emphasised label, typically the left column of a key/value
// row.
func (t *Theme) Secondary(txt string) material.LabelStyle {
return t.Text(SizeBody, t.P.TextSec, txt)
}
// Caption is metadata: timestamps, hints, units.
func (t *Theme) Caption(txt string) material.LabelStyle {
return t.Text(SizeCaption, t.P.TextDim, txt)
}
// OneLine constrains a label to a single truncated line, which keeps table
// rows from reflowing when a peer has a long name.
func OneLine(l material.LabelStyle) material.LabelStyle {
l.MaxLines = 1
l.WrapPolicy = text.WrapGraphemes
return l
}
// ---------------------------------------------------------------------------
// Primitive drawing helpers
// ---------------------------------------------------------------------------
// FillRRect paints a rounded rectangle of the given size.
func FillRRect(gtx C, size image.Point, radius unit.Dp, col color.NRGBA) {
r := gtx.Dp(radius)
if max := min(size.X, size.Y) / 2; r > max {
r = max
}
paint.FillShape(gtx.Ops, col, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops))
}
// StrokeRRect outlines a rounded rectangle.
func StrokeRRect(gtx C, size image.Point, radius unit.Dp, width unit.Dp, col color.NRGBA) {
r := gtx.Dp(radius)
if max := min(size.X, size.Y) / 2; r > max {
r = max
}
w := float32(gtx.Dp(width))
// Inset by half the stroke width so the outline lands inside the bounds.
inset := int(w / 2)
rect := image.Rectangle{Min: image.Pt(inset, inset), Max: size.Sub(image.Pt(inset, inset))}
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return
}
spec := clip.UniformRRect(rect, r).Path(gtx.Ops)
paint.FillShape(gtx.Ops, col, clip.Stroke{Path: spec, Width: w}.Op())
}
// Circle paints a filled circle of the given diameter.
func Circle(gtx C, diameter int, col color.NRGBA) D {
if diameter <= 0 {
return D{}
}
r := diameter / 2
paint.FillShape(gtx.Ops, col,
clip.UniformRRect(image.Rectangle{Max: image.Pt(diameter, diameter)}, r).Op(gtx.Ops))
return D{Size: image.Pt(diameter, diameter)}
}
// animFrame is the minimum gap between animation frames, i.e. a ~25fps cap.
//
// This matters more than it looks. op.InvalidateCmd with a zero At means
// "redraw immediately", so a widget that issues one every frame makes Gio
// render as fast as the machine can manage — several hundred percent CPU under
// software rendering, for a spinner nobody is watching. Scheduling the next
// frame at a fixed time bounds the loop, and concurrent animations coalesce
// onto the same wakeup.
//
// The cap alone is not enough, because a frame is not cheap: profiling this UI
// under llvmpipe put 73% of the time in Gio's path stenciler, which every
// rounded rectangle, border and icon goes through. So animation is also
// reserved for genuinely transient states — see [Theme.StatusDot]. An idle
// window must settle at zero frames per second, not a slow trickle.
const animFrame = 40 * time.Millisecond
// animSlowFrame is the cadence for ambient motion with a multi-second cycle,
// where 12fps is indistinguishable from 25 but costs half as much.
const animSlowFrame = 80 * time.Millisecond
// animate requests the next animation frame at the capped rate. Every animated
// widget in this package goes through it.
func animate(gtx C) {
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animFrame)})
}
// animateSlow is [animate] for slow, decorative motion.
func animateSlow(gtx C) {
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animSlowFrame)})
}
// Spacer returns a fixed-size gap.
func Spacer(v unit.Dp) layout.Spacer { return layout.Spacer{Height: v, Width: v} }
// VGap is a vertical gap.
func VGap(v unit.Dp) layout.FlexChild {
return layout.Rigid(layout.Spacer{Height: v}.Layout)
}
// HGap is a horizontal gap.
func HGap(v unit.Dp) layout.FlexChild {
return layout.Rigid(layout.Spacer{Width: v}.Layout)
}
// Divider draws a hairline separator.
func (t *Theme) Divider(gtx C) D {
h := max(gtx.Dp(1), 1)
w := gtx.Constraints.Min.X
if w == 0 {
w = gtx.Constraints.Max.X
}
paint.FillShape(gtx.Ops, t.P.Border, clip.Rect{Max: image.Pt(w, h)}.Op())
return D{Size: image.Pt(w, h)}
}
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
// CardStyle is the standard container: a slightly raised surface with a
// hairline border. Cards are the only container in the UI, which is what keeps
// dense pages from turning into noise.
type CardStyle struct {
Title string
Subtitle string
// Accent tints the left edge, used to flag a section's severity without
// adding another coloured chip.
Accent *color.NRGBA
// Trailing renders at the top-right of the header, for actions.
Trailing layout.Widget
Pad unit.Dp
Radius unit.Dp
Bg *color.NRGBA
}
// Card returns a default card.
func (t *Theme) Card() CardStyle {
return CardStyle{Pad: SpaceLG, Radius: RadiusMD}
}
// Layout draws the card around w.
func (c CardStyle) Layout(t *Theme, gtx C, w layout.Widget) D {
bg := t.P.Surface
if c.Bg != nil {
bg = *c.Bg
}
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
size := gtx.Constraints.Min
FillRRect(gtx, size, c.Radius, bg)
StrokeRRect(gtx, size, c.Radius, 1, t.P.Border)
if c.Accent != nil {
// A 3dp bar hugging the left edge, clipped to the card radius.
r := gtx.Dp(c.Radius)
defer clip.UniformRRect(image.Rectangle{Max: size}, r).Push(gtx.Ops).Pop()
paint.FillShape(gtx.Ops, *c.Accent,
clip.Rect{Max: image.Pt(gtx.Dp(3), size.Y)}.Op())
}
return D{Size: size}
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.UniformInset(c.Pad).Layout(gtx, func(gtx C) D {
if c.Title == "" {
return w(gtx)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return c.header(t, gtx)
}),
VGap(SpaceMD),
layout.Rigid(w),
)
})
}),
)
}
func (c CardStyle) header(t *Theme, gtx C) D {
return layout.Flex{Axis: layout.Horizontal, 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 {
l := t.Text(SizeSubtitle, t.P.TextPri, c.Title)
l.Font.Weight = font.SemiBold
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if c.Subtitle == "" {
return D{}
}
return layout.Inset{Top: 2}.Layout(gtx, t.Caption(c.Subtitle).Layout)
}),
)
}),
layout.Rigid(func(gtx C) D {
if c.Trailing == nil {
return D{}
}
return c.Trailing(gtx)
}),
)
}
// ---------------------------------------------------------------------------
// Chips, dots, badges
// ---------------------------------------------------------------------------
// ChipStyle is a small pill carrying one piece of status.
type ChipStyle struct {
Text string
Level StatusLevel
// Solid fills the chip with the level colour instead of tinting it.
Solid bool
// Dot prefixes the label with a status dot.
Dot bool
}
// Chip renders a status pill.
func (t *Theme) Chip(gtx C, s ChipStyle) D {
fg := t.StatusColor(s.Level)
bg := WithAlpha(fg, 0.14)
if s.Solid {
bg = fg
fg = t.P.AccentFg
}
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
FillRRect(gtx, gtx.Constraints.Min, RadiusPill, bg)
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
return layout.Inset{
Top: 3, Bottom: 3, Left: SpaceSM, Right: SpaceSM,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
if !s.Dot {
return D{}
}
return layout.Inset{Right: 5}.Layout(gtx, func(gtx C) D {
return Circle(gtx, gtx.Dp(6), fg)
})
}),
layout.Rigid(OneLine(t.Text(SizeCaption, fg, s.Text)).Layout),
)
})
}),
)
}
// StatusDot draws a coloured dot; when pulse is true it breathes.
//
// Pass pulse only for states that are actually transient — connecting,
// retrying, a probe in flight. A dot that breathes forever costs a full
// redraw of the window several times a second for as long as the app is open,
// which is not a price worth paying to say "still here".
func (t *Theme) StatusDot(gtx C, level StatusLevel, pulse bool) D {
col := t.StatusColor(level)
d := gtx.Dp(8)
if pulse {
// One breath per 1.6s, derived from frame time so it stays smooth.
phase := float64(gtx.Now.UnixNano()%int64(1600*time.Millisecond)) / float64(1600*time.Millisecond)
a := 0.35 + 0.65*(0.5+0.5*math.Sin(phase*2*math.Pi))
halo := WithAlpha(col, float32(a)*0.35)
hd := gtx.Dp(16)
off := op.Offset(image.Pt(-(hd-d)/2, -(hd-d)/2)).Push(gtx.Ops)
Circle(gtx, hd, halo)
off.Pop()
animate(gtx)
}
return Circle(gtx, d, col)
}
// ---------------------------------------------------------------------------
// Key/value rows
// ---------------------------------------------------------------------------
// KV renders a label on the left and a value on the right. This is the primary
// way facts are shown; keeping every panel on the same row grammar is what
// makes a dense diagnostics page scannable.
type KV struct {
Key string
Value string
// Level colours the value. LevelNeutral leaves it primary-coloured.
Level StatusLevel
// Mono renders the value monospaced.
Mono bool
// Hint appears under the key in caption style.
Hint string
// KeyWidth fixes the label column so consecutive rows align. Zero uses a
// flexible 40% split.
KeyWidth unit.Dp
}
// Layout draws one key/value row.
func (t *Theme) KV(gtx C, kv KV) D {
valCol := t.P.TextPri
if kv.Level != LevelNeutral {
valCol = t.StatusColor(kv.Level)
}
value := func(gtx C) D {
var l material.LabelStyle
if kv.Mono {
l = t.MonoLabel(SizeBody, valCol, kv.Value)
} else {
l = t.Text(SizeBody, valCol, kv.Value)
}
l.Alignment = text.End
return l.Layout(gtx)
}
key := func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(OneLine(t.Secondary(kv.Key)).Layout),
layout.Rigid(func(gtx C) D {
if kv.Hint == "" {
return D{}
}
return t.Caption(kv.Hint).Layout(gtx)
}),
)
}
return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D {
if kv.KeyWidth > 0 {
w := gtx.Dp(kv.KeyWidth)
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints.Max.X = w
gtx.Constraints.Min.X = w
return key(gtx)
}),
HGap(SpaceMD),
layout.Flexed(1, value),
)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(0.42, key),
HGap(SpaceMD),
layout.Flexed(0.58, value),
)
})
}
// KVList lays out consecutive rows with hairlines between them.
func (t *Theme) KVList(gtx C, rows []KV) D {
children := make([]layout.FlexChild, 0, len(rows)*2)
for i, row := range rows {
if i > 0 {
children = append(children, layout.Rigid(t.Divider))
}
children = append(children, layout.Rigid(func(gtx C) D {
return t.KV(gtx, row)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
// ---------------------------------------------------------------------------
// Buttons
// ---------------------------------------------------------------------------
// ButtonKind selects a button's visual weight. A screen should have at most
// one Primary.
type ButtonKind int
const (
ButtonPrimary ButtonKind = iota
ButtonSubtle
ButtonGhost
ButtonDanger
)
// ButtonStyle is this app's button, replacing material.Button so hover, radius
// and typography match the rest of the design.
type ButtonStyle struct {
Kind ButtonKind
Text string
Icon IconFunc
Disabled bool
// Width, when non-zero, fixes the button width for aligned button rows.
Width unit.Dp
}
// Button renders a clickable button.
func (t *Theme) Button(gtx C, click *widget.Clickable, s ButtonStyle) D {
var bg, fg, border color.NRGBA
switch s.Kind {
case ButtonPrimary:
bg, fg = t.P.Accent, t.P.AccentFg
case ButtonDanger:
bg, fg = t.P.Fail, t.P.AccentFg
case ButtonSubtle:
bg, fg, border = t.P.SurfaceHi, t.P.TextPri, t.P.Border
default: // ghost
bg, fg = color.NRGBA{}, t.P.TextSec
}
if s.Disabled {
bg = WithAlpha(bg, 0.4)
fg = WithAlpha(fg, 0.45)
gtx = gtx.Disabled()
} else if click.Hovered() {
switch s.Kind {
case ButtonGhost:
bg = t.P.SurfaceHi
fg = t.P.TextPri
default:
bg = Mix(bg, t.P.TextPri, 0.12)
}
}
if click.Pressed() {
bg = Mix(bg, t.P.Bg, 0.18)
}
return click.Layout(gtx, func(gtx C) D {
if s.Width > 0 {
gtx.Constraints.Min.X = gtx.Dp(s.Width)
}
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
if bg.A > 0 {
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, bg)
}
if border.A > 0 {
StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, border)
}
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
return layout.Inset{
Top: 7, Bottom: 7, Left: SpaceMD, Right: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
if s.Icon == nil {
return D{}
}
return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D {
return s.Icon(gtx, gtx.Dp(14), fg)
})
}),
layout.Rigid(func(gtx C) D {
if s.Text == "" {
return D{}
}
l := t.Text(SizeBody, fg, s.Text)
l.Font.Weight = font.Medium
l.Alignment = text.Middle
return l.Layout(gtx)
}),
)
})
}),
)
})
}
// IconButton is a square icon-only button, used in card headers.
func (t *Theme) IconButton(gtx C, click *widget.Clickable, icon IconFunc, level StatusLevel) D {
fg := t.P.TextSec
if level != LevelNeutral {
fg = t.StatusColor(level)
}
bg := color.NRGBA{}
if click.Hovered() {
bg = t.P.SurfaceHi
if level == LevelNeutral {
fg = t.P.TextPri
}
}
return click.Layout(gtx, func(gtx C) D {
sz := gtx.Dp(28)
if bg.A > 0 {
FillRRect(gtx, image.Pt(sz, sz), RadiusSM, bg)
}
icoSize := gtx.Dp(16)
off := op.Offset(image.Pt((sz-icoSize)/2, (sz-icoSize)/2)).Push(gtx.Ops)
icon(gtx, icoSize, fg)
off.Pop()
return D{Size: image.Pt(sz, sz)}
})
}
// ---------------------------------------------------------------------------
// Toggle
// ---------------------------------------------------------------------------
// Toggle renders a compact switch with a label.
func (t *Theme) Toggle(gtx C, b *widget.Bool, label string) D {
return b.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
w, h := gtx.Dp(32), gtx.Dp(18)
track := t.P.SurfaceHi
knobCol := t.P.TextDim
if b.Value {
track = t.P.Accent
knobCol = t.P.AccentFg
}
FillRRect(gtx, image.Pt(w, h), RadiusPill, track)
kd := h - gtx.Dp(4)
kx := gtx.Dp(2)
if b.Value {
kx = w - kd - gtx.Dp(2)
}
off := op.Offset(image.Pt(kx, gtx.Dp(2))).Push(gtx.Ops)
Circle(gtx, kd, knobCol)
off.Pop()
return D{Size: image.Pt(w, h)}
}),
layout.Rigid(func(gtx C) D {
if label == "" {
return D{}
}
return layout.Inset{Left: SpaceSM}.Layout(gtx, t.Secondary(label).Layout)
}),
)
})
}
// ---------------------------------------------------------------------------
// Segmented control (used for level/language/theme pickers)
// ---------------------------------------------------------------------------
// SegmentOption is one choice in a segmented control.
type SegmentOption struct {
Key string
Label string
// Count, when non-negative, is shown as a trailing tally.
Count int
Level StatusLevel
}
// Segmented renders a row of mutually exclusive options backed by a
// widget.Enum.
func (t *Theme) Segmented(gtx C, e *widget.Enum, opts []SegmentOption) D {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, t.P.BgElevated)
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
return layout.UniformInset(3).Layout(gtx, func(gtx C) D {
children := make([]layout.FlexChild, 0, len(opts))
for _, o := range opts {
children = append(children, layout.Rigid(func(gtx C) D {
return t.segment(gtx, e, o)
}))
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...)
})
}),
)
}
func (t *Theme) segment(gtx C, e *widget.Enum, o SegmentOption) D {
selected := e.Value == o.Key
fg := t.P.TextSec
if selected {
fg = t.P.TextPri
}
if o.Level != LevelNeutral && selected {
fg = t.StatusColor(o.Level)
}
return e.Layout(gtx, o.Key, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
if selected {
FillRRect(gtx, gtx.Constraints.Min, RadiusSM-2, t.P.SurfaceHi)
}
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
return layout.Inset{Top: 4, Bottom: 4, Left: SpaceMD, Right: SpaceMD}.Layout(gtx, func(gtx C) D {
label := o.Label
if o.Count >= 0 {
label = o.Label + " " + itoa(o.Count)
}
l := t.Text(SizeCaption, fg, label)
if selected {
l.Font.Weight = font.Medium
}
return l.Layout(gtx)
})
}),
)
})
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
// EmptyState is what a panel shows instead of a blank area. It always says why
// the area is empty, never just "no data".
func (t *Theme) EmptyState(gtx C, icon IconFunc, title, hint string) D {
return layout.Center.Layout(gtx, func(gtx C) D {
return layout.Inset{Top: Space2XL, Bottom: Space2XL}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
if icon == nil {
return D{}
}
return icon(gtx, gtx.Dp(28), WithAlpha(t.P.TextDim, 0.7))
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
l := t.Text(SizeBody, t.P.TextSec, title)
l.Alignment = text.Middle
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if hint == "" {
return D{}
}
return layout.Inset{Top: SpaceXS}.Layout(gtx, func(gtx C) D {
l := t.Caption(hint)
l.Alignment = text.Middle
return l.Layout(gtx)
})
}),
)
})
})
}
// ---------------------------------------------------------------------------
// Spinner
// ---------------------------------------------------------------------------
// Spinner draws an indeterminate arc. It requests the next frame itself, so
// callers just place it.
func (t *Theme) Spinner(gtx C, size int, col color.NRGBA) D {
if size <= 0 {
size = gtx.Dp(20)
}
const period = 1100 * time.Millisecond
phase := float32(gtx.Now.UnixNano()%int64(period)) / float32(period)
stroke := float32(gtx.Dp(2))
r := float32(size)/2 - stroke/2
center := f32.Pt(float32(size)/2, float32(size)/2)
// Track.
drawArc(gtx, center, r, stroke, 0, 2*math.Pi, WithAlpha(col, 0.15))
// Sweep: the arc length breathes so the motion reads as progress rather
// than a rotating stick.
sweep := float32(0.25*math.Pi) + float32(1.2*math.Pi)*(0.5+0.5*float32(math.Sin(float64(phase)*2*math.Pi)))
start := phase * 2 * math.Pi * 2
drawArc(gtx, center, r, stroke, start, sweep, col)
animate(gtx)
return D{Size: image.Pt(size, size)}
}
// drawArc strokes an arc of `sweep` radians starting at `start`.
func drawArc(gtx C, center f32.Point, radius, width, start, sweep float32, col color.NRGBA) {
if radius <= 0 || sweep <= 0 {
return
}
var p clip.Path
p.Begin(gtx.Ops)
begin := f32.Pt(
center.X+radius*float32(math.Cos(float64(start))),
center.Y+radius*float32(math.Sin(float64(start))),
)
p.MoveTo(begin)
// clip.Path.Arc rotates the pen around the focus points; for a circle both
// foci are the centre.
p.Arc(center.Sub(begin), center.Sub(begin), sweep)
paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: width}.Op())
}
// ProgressBar draws a determinate bar in [0,1].
func (t *Theme) ProgressBar(gtx C, progress float32, col color.NRGBA) D {
if progress < 0 {
progress = 0
}
if progress > 1 {
progress = 1
}
w := gtx.Constraints.Max.X
h := gtx.Dp(4)
FillRRect(gtx, image.Pt(w, h), RadiusPill, WithAlpha(col, 0.16))
fw := int(float32(w) * progress)
if fw > 0 {
FillRRect(gtx, image.Pt(fw, h), RadiusPill, col)
}
return D{Size: image.Pt(w, h)}
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// FormatLatency renders a duration the way a network tool should: sub-10ms
// gets one decimal, everything else is a whole number of milliseconds.
func FormatLatency(d time.Duration) string {
if d <= 0 {
return "—"
}
ms := float64(d) / float64(time.Millisecond)
switch {
case ms < 10:
return trimZero(ms, 1) + " ms"
case ms < 1000:
return itoa(int(ms+0.5)) + " ms"
default:
return trimZero(ms/1000, 2) + " s"
}
}
func trimZero(v float64, prec int) string {
mult := math.Pow(10, float64(prec))
v = math.Round(v*mult) / mult
s := strconvFormat(v, prec)
if strings.Contains(s, ".") {
s = strings.TrimRight(s, "0")
s = strings.TrimSuffix(s, ".")
}
return s
}
// strconvFormat avoids importing strconv just for one call site pattern; it
// formats with a fixed number of decimals.
func strconvFormat(v float64, prec int) string {
neg := v < 0
if neg {
v = -v
}
mult := math.Pow(10, float64(prec))
scaled := int64(math.Round(v * mult))
intPart := scaled / int64(mult)
frac := scaled % int64(mult)
s := itoa(int(intPart))
if prec > 0 {
fs := itoa(int(frac))
for len(fs) < prec {
fs = "0" + fs
}
s += "." + fs
}
if neg {
s = "-" + s
}
return s
}
// FormatBytes renders a byte count with binary units.
func FormatBytes(n int64) string {
if n < 0 {
return "—"
}
const unit = 1024
if n < unit {
return itoa(int(n)) + " B"
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit && exp < 4; v /= unit {
div *= unit
exp++
}
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
return trimZero(float64(n)/float64(div), 1) + " " + suffixes[exp]
}
// FormatDuration renders an uptime-style duration.
func FormatDuration(d time.Duration) string {
if d <= 0 {
return "—"
}
d = d.Round(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
switch {
case h >= 24:
return itoa(h/24) + "d " + itoa(h%24) + "h"
case h > 0:
return itoa(h) + "h " + itoa(m) + "m"
case m > 0:
return itoa(m) + "m " + itoa(s) + "s"
default:
return itoa(s) + "s"
}
}
// RelTime renders how long ago t was, localised.
func RelTime(th *Theme, t time.Time, now time.Time) string {
if t.IsZero() {
return th.T(KNever)
}
d := now.Sub(t)
switch {
case d < 0:
return th.T(KJustNow)
case d < 5*time.Second:
return th.T(KJustNow)
case d < time.Minute:
return itoa(int(d.Seconds())) + th.T(KSecondsAgo)
case d < time.Hour:
return itoa(int(d.Minutes())) + th.T(KMinutesAgo)
case d < 24*time.Hour:
return itoa(int(d.Hours())) + th.T(KHoursAgo)
default:
return t.Format("01-02 15:04")
}
}
// Truncate shortens s to at most n runes, appending an ellipsis.
func Truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
if n <= 1 {
return "…"
}
return string(r[:n-1]) + "…"
}