add: gui and tsdiag
This commit is contained in:
+677
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user