fix gui
This commit is contained in:
+93
-29
@@ -12,12 +12,12 @@ import (
|
||||
"gioui.org/app"
|
||||
"gioui.org/font"
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/system"
|
||||
"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"
|
||||
@@ -43,7 +43,6 @@ type pageID int
|
||||
const (
|
||||
pageOverview pageID = iota
|
||||
pagePeers
|
||||
pageLan
|
||||
pageDiag
|
||||
pageLogs
|
||||
pageSettings
|
||||
@@ -71,16 +70,21 @@ type App struct {
|
||||
|
||||
overview *overviewPage
|
||||
peers *peersPage
|
||||
lan *lanPage
|
||||
diag *diagPage
|
||||
logs *logsPage
|
||||
settings *settingsPage
|
||||
|
||||
splash *splashView
|
||||
overlay *logOverlay
|
||||
splash *splashView
|
||||
|
||||
overlayBtn widget.Clickable
|
||||
themeBtn widget.Clickable
|
||||
themeBtn widget.Clickable
|
||||
|
||||
// grown records that the window has already been resized from its compact
|
||||
// splash dimensions to the full shell. The splash sizes the window to just
|
||||
// its progress bar and checklist, so the transition to the shell has to grow
|
||||
// it — exactly once, or a user who resized the window would have it snapped
|
||||
// back every time the service restarted. Atomic because the resize runs on
|
||||
// the watch goroutine, not the UI one.
|
||||
grown atomic.Bool
|
||||
|
||||
toastMsg string
|
||||
toastLevel StatusLevel
|
||||
@@ -121,19 +125,16 @@ func New(opt Options) *App {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -141,15 +142,24 @@ func New(opt Options) *App {
|
||||
// closes.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
w := new(app.Window)
|
||||
// Opens at splash size; grown to the shell dimensions below once the
|
||||
// service is ready.
|
||||
w.Option(
|
||||
app.Title("tslink"),
|
||||
app.Size(unit.Dp(1120), unit.Dp(740)),
|
||||
app.MinSize(unit.Dp(880), unit.Dp(560)),
|
||||
app.Size(splashWindowW, splashWindowH),
|
||||
app.MinSize(splashMinW, splashMinH),
|
||||
)
|
||||
a.win = w
|
||||
|
||||
go a.watch(ctx, w)
|
||||
go a.upgradeFonts()
|
||||
// Ctrl+C at the terminal cancels ctx. Without this the supervisor tears
|
||||
// down but the window survives, dropping the user back to the splash — the
|
||||
// GUI is the process, so cancelling it has to close the window too.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
w.Perform(system.ActionClose)
|
||||
}()
|
||||
|
||||
var ops op.Ops
|
||||
for {
|
||||
@@ -161,10 +171,34 @@ func (a *App) Run(ctx context.Context) error {
|
||||
a.applyFontUpgrade()
|
||||
a.layout(gtx)
|
||||
e.Frame(gtx.Ops)
|
||||
// After the frame, so the resize is not applied midway through
|
||||
// laying one out against the old constraints. Once grown this is a
|
||||
// single atomic load.
|
||||
if !a.grown.Load() && a.state().Ready() {
|
||||
a.grow(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// grow resizes the window from its compact splash dimensions to the full shell,
|
||||
// once, when the service comes up.
|
||||
//
|
||||
// It must run on the goroutine that drives the event loop. On Linux the driver
|
||||
// executes Window.Option's work inline on the calling goroutine rather than
|
||||
// handing it to a UI thread, so calling this from anywhere else would mutate
|
||||
// driver state concurrently with event dispatch.
|
||||
func (a *App) grow(w *app.Window) {
|
||||
if a.grown.Swap(true) {
|
||||
return
|
||||
}
|
||||
w.Option(
|
||||
app.Size(shellWindowW, shellWindowH),
|
||||
app.MinSize(shellMinW, shellMinH),
|
||||
)
|
||||
w.Invalidate()
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -295,6 +329,18 @@ func (a *App) copyToClipboard(gtx C, s string, msg string) {
|
||||
a.notify(msg, LevelOK)
|
||||
}
|
||||
|
||||
// reveal shows path in the platform file manager, off the UI goroutine so a
|
||||
// slow or missing file manager cannot stall a frame. Failure is logged rather
|
||||
// than surfaced: the file is already written and its path is already on screen,
|
||||
// so there is nothing for the user to act on.
|
||||
func (a *App) reveal(path string) {
|
||||
go func() {
|
||||
if err := RevealInFileManager(path, a.logger); err != nil {
|
||||
a.logger.Warn("could not open the file manager", "path", path, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// notify shows a transient message at the bottom of the window.
|
||||
func (a *App) notify(msg string, level StatusLevel) {
|
||||
a.toastMsg = msg
|
||||
@@ -325,9 +371,6 @@ func (a *App) layout(gtx C) D {
|
||||
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)
|
||||
}
|
||||
@@ -341,10 +384,6 @@ func (a *App) layout(gtx C) D {
|
||||
}
|
||||
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)
|
||||
@@ -379,8 +418,6 @@ 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:
|
||||
@@ -536,14 +573,6 @@ func (a *App) header(gtx C, st core.State) D {
|
||||
}),
|
||||
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)
|
||||
}),
|
||||
@@ -675,3 +704,38 @@ func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
+80
-23
@@ -40,9 +40,12 @@ type ChartSeries struct {
|
||||
// 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.
|
||||
// MaxWindow caps how far back the x axis reaches. The axis is scaled to the
|
||||
// data's own extent and only clamped by this, so the plot fills its width
|
||||
// from the second sample onward instead of leaving the first N minutes of
|
||||
// the window blank while history accumulates.
|
||||
MaxWindow time.Duration
|
||||
// Now is the wall clock, used only as a fallback when there is no data.
|
||||
Now time.Time
|
||||
// Unit labels the y axis.
|
||||
Unit string
|
||||
@@ -57,18 +60,68 @@ type Chart struct {
|
||||
hovering bool
|
||||
// plot is the last plotted rectangle, used to map hover x back to a time.
|
||||
plot image.Rectangle
|
||||
// tMin/tMax are the x domain resolved by the last Layout. HoverIndex maps
|
||||
// the pointer through these rather than recomputing from ChartStyle, so the
|
||||
// crosshair cannot disagree with the drawn line.
|
||||
tMin, tMax time.Time
|
||||
}
|
||||
|
||||
// minPlotSpan keeps the axis sane when every visible sample shares a timestamp,
|
||||
// which happens on the very first frame after a refresh.
|
||||
const minPlotSpan = 10 * time.Second
|
||||
|
||||
// domain resolves the x axis from the visible data, clamped to st.MaxWindow.
|
||||
func domain(series []ChartSeries, st ChartStyle) (tMin, tMax time.Time) {
|
||||
now := st.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
window := st.MaxWindow
|
||||
if window <= 0 {
|
||||
window = 3 * time.Minute
|
||||
}
|
||||
|
||||
var first, last time.Time
|
||||
for _, s := range series {
|
||||
if s.Hidden {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.Points {
|
||||
if first.IsZero() || p.At.Before(first) {
|
||||
first = p.At
|
||||
}
|
||||
if last.IsZero() || p.At.After(last) {
|
||||
last = p.At
|
||||
}
|
||||
}
|
||||
}
|
||||
if first.IsZero() {
|
||||
return now.Add(-window), now
|
||||
}
|
||||
// Never show more than the window, however much history is retained.
|
||||
if last.Sub(first) > window {
|
||||
first = last.Add(-window)
|
||||
}
|
||||
if last.Sub(first) < minPlotSpan {
|
||||
first = last.Add(-minPlotSpan)
|
||||
}
|
||||
return first, last
|
||||
}
|
||||
|
||||
// HoverIndex returns the sample index the pointer is nearest within s, or -1.
|
||||
func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
|
||||
func (c *Chart) HoverIndex(series ChartSeries) int {
|
||||
if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 {
|
||||
return -1
|
||||
}
|
||||
span := c.tMax.Sub(c.tMin)
|
||||
if span <= 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)))
|
||||
target := c.tMin.Add(time.Duration(frac * float64(span)))
|
||||
best, bestDelta := -1, time.Duration(math.MaxInt64)
|
||||
for i, p := range series.Points {
|
||||
d := p.At.Sub(target)
|
||||
@@ -81,7 +134,7 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
|
||||
}
|
||||
// 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 {
|
||||
if bestDelta > span/20 {
|
||||
return -1
|
||||
}
|
||||
return best
|
||||
@@ -89,9 +142,6 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -114,16 +164,16 @@ func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D {
|
||||
c.update(gtx, size)
|
||||
|
||||
yMax := niceMax(maxVisible(series))
|
||||
tMin := st.Now.Add(-st.Window)
|
||||
c.tMin, c.tMax = domain(series, st)
|
||||
|
||||
c.drawGrid(t, gtx, plot, yMax, st)
|
||||
c.drawGrid(t, gtx, plot, yMax, c.tMax.Sub(c.tMin))
|
||||
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.drawSeries(t, gtx, plot, s, c.tMin, c.tMax, yMax, st.FillSingle && visibleCount(series) == 1)
|
||||
}
|
||||
c.drawCrosshair(t, gtx, plot, series, st, tMin, yMax)
|
||||
c.drawCrosshair(t, gtx, plot, series, yMax)
|
||||
|
||||
return D{Size: size}
|
||||
}
|
||||
@@ -199,7 +249,7 @@ func niceMax(v float64) float64 {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st ChartStyle) {
|
||||
func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, span time.Duration) {
|
||||
const rows = 4
|
||||
lineCol := WithAlpha(t.P.Border, 0.9)
|
||||
for i := 0; i <= rows; i++ {
|
||||
@@ -226,8 +276,8 @@ func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st
|
||||
frac float64
|
||||
txt string
|
||||
}{
|
||||
{0, "-" + FormatDuration(st.Window)},
|
||||
{0.5, "-" + FormatDuration(st.Window/2)},
|
||||
{0, "-" + FormatDuration(span)},
|
||||
{0.5, "-" + FormatDuration(span/2)},
|
||||
{1, "now"},
|
||||
}
|
||||
if t.Lang == LangZH {
|
||||
@@ -354,7 +404,7 @@ func (c *Chart) drawSeries(t *Theme, gtx C, plot image.Rectangle, s ChartSeries,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, st ChartStyle, tMin time.Time, yMax float64) {
|
||||
func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, yMax float64) {
|
||||
if !c.hovering {
|
||||
return
|
||||
}
|
||||
@@ -371,11 +421,11 @@ func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []Ch
|
||||
if s.Hidden {
|
||||
continue
|
||||
}
|
||||
i := c.HoverIndex(s, st)
|
||||
i := c.HoverIndex(s)
|
||||
if i < 0 || !s.Points[i].OK {
|
||||
continue
|
||||
}
|
||||
pt := pos(plot, tMin, st.Now, yMax, s.Points[i])
|
||||
pt := pos(plot, c.tMin, c.tMax, 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)
|
||||
@@ -402,15 +452,20 @@ type LegendEntry struct {
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Wrapping matters here: with the eight series the chart allows, the chips are
|
||||
// far wider than the card, and a plain Flex would silently clip the trailing
|
||||
// ones — the peers you could no longer toggle were exactly the ones you could
|
||||
// no longer identify.
|
||||
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))
|
||||
children := make([]layout.Widget, 0, len(entries))
|
||||
for i := range entries {
|
||||
children = append(children, layout.Rigid(click(i)))
|
||||
children = append(children, click(i))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...)
|
||||
return WrapRow(gtx, 0, children)
|
||||
}
|
||||
|
||||
// LegendChip draws one legend entry.
|
||||
@@ -434,7 +489,9 @@ func (t *Theme) LegendChip(gtx C, e LegendEntry, hovered bool) D {
|
||||
return D{Size: image.Pt(w, h)}
|
||||
})
|
||||
}),
|
||||
layout.Rigid(OneLine(t.Text(SizeCaption, fg, e.Name)).Layout),
|
||||
// Bounded: peer names can be long, and one runaway chip would push
|
||||
// every following one onto its own line.
|
||||
layout.Rigid(OneLine(t.Text(SizeCaption, fg, Truncate(e.Name, 22))).Layout),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if e.Value == "" {
|
||||
return D{}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
|
||||
"tslink/netdiag"
|
||||
)
|
||||
|
||||
// TestChartDomainFillsWithSparseData is the regression for the blank-chart bug:
|
||||
// a handful of samples used to occupy the left 3% of a fixed 20-minute axis.
|
||||
// The domain must track the data, not the clock.
|
||||
func TestChartDomainFillsWithSparseData(t *testing.T) {
|
||||
now := time.Now()
|
||||
// 30 seconds of uptime at the 10s ping interval.
|
||||
pts := []ChartPoint{
|
||||
{At: now.Add(-20 * time.Second), Value: 10, OK: true},
|
||||
{At: now.Add(-10 * time.Second), Value: 12, OK: true},
|
||||
{At: now, Value: 11, OK: true},
|
||||
}
|
||||
series := []ChartSeries{{Points: pts}}
|
||||
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
|
||||
|
||||
tMin, tMax := domain(series, st)
|
||||
if got := tMax.Sub(tMin); got != 20*time.Second {
|
||||
t.Fatalf("span = %v, want the data's own 20s extent", got)
|
||||
}
|
||||
if !tMin.Equal(pts[0].At) || !tMax.Equal(pts[2].At) {
|
||||
t.Errorf("domain = [%v, %v], want the first and last sample", tMin, tMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartDomainClampsToWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
series := []ChartSeries{{Points: []ChartPoint{
|
||||
{At: now.Add(-30 * time.Minute), Value: 10, OK: true},
|
||||
{At: now, Value: 11, OK: true},
|
||||
}}}
|
||||
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
|
||||
|
||||
tMin, tMax := domain(series, st)
|
||||
if got := tMax.Sub(tMin); got != 3*time.Minute {
|
||||
t.Fatalf("span = %v, want it clamped to MaxWindow", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartDomainEdgeCases(t *testing.T) {
|
||||
now := time.Now()
|
||||
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
|
||||
|
||||
// No data at all: fall back to the full window so the grid still renders.
|
||||
tMin, tMax := domain(nil, st)
|
||||
if got := tMax.Sub(tMin); got != 3*time.Minute {
|
||||
t.Errorf("empty span = %v, want the full window", got)
|
||||
}
|
||||
|
||||
// One sample would otherwise give a zero-width axis and divide by zero.
|
||||
one := []ChartSeries{{Points: []ChartPoint{{At: now, Value: 5, OK: true}}}}
|
||||
tMin, tMax = domain(one, st)
|
||||
if got := tMax.Sub(tMin); got != minPlotSpan {
|
||||
t.Errorf("single-point span = %v, want minPlotSpan", got)
|
||||
}
|
||||
|
||||
// Hidden series must not widen the axis.
|
||||
mixed := []ChartSeries{
|
||||
{Hidden: true, Points: []ChartPoint{{At: now.Add(-2 * time.Minute), Value: 1, OK: true}}},
|
||||
{Points: []ChartPoint{
|
||||
{At: now.Add(-30 * time.Second), Value: 1, OK: true},
|
||||
{At: now, Value: 2, OK: true},
|
||||
}},
|
||||
}
|
||||
tMin, tMax = domain(mixed, st)
|
||||
if got := tMax.Sub(tMin); got != 30*time.Second {
|
||||
t.Errorf("span = %v, want only the visible series to count", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapRowWraps checks that children exceeding the width land on new lines
|
||||
// instead of being clipped, which is what a plain Flex did.
|
||||
func TestWrapRowWraps(t *testing.T) {
|
||||
const (
|
||||
childW = 100
|
||||
childH = 20
|
||||
rowW = 250 // fits 2 children per line
|
||||
n = 5
|
||||
)
|
||||
child := func(gtx C) D { return D{Size: image.Pt(childW, childH)} }
|
||||
children := make([]layout.Widget, n)
|
||||
for i := range children {
|
||||
children[i] = child
|
||||
}
|
||||
|
||||
gtx, _ := newTestContext(image.Pt(rowW, 500))
|
||||
dims := WrapRow(gtx, 0, children)
|
||||
|
||||
// 5 children, 2 per line => 3 lines.
|
||||
if want := 3 * childH; dims.Size.Y != want {
|
||||
t.Errorf("height = %d, want %d (3 wrapped lines)", dims.Size.Y, want)
|
||||
}
|
||||
if dims.Size.X != rowW {
|
||||
t.Errorf("width = %d, want the full %d", dims.Size.X, rowW)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapRowSingleLine(t *testing.T) {
|
||||
child := func(gtx C) D { return D{Size: image.Pt(50, 20)} }
|
||||
gtx, _ := newTestContext(image.Pt(500, 500))
|
||||
dims := WrapRow(gtx, 0, []layout.Widget{child, child, child})
|
||||
if dims.Size.Y != 20 {
|
||||
t.Errorf("height = %d, want a single 20px line", dims.Size.Y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapRowEmpty(t *testing.T) {
|
||||
gtx, _ := newTestContext(image.Pt(100, 100))
|
||||
if dims := WrapRow(gtx, 0, nil); dims.Size != (image.Point{}) {
|
||||
t.Errorf("want zero dims for no children, got %v", dims.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUDPProbeLabel covers the naming rules for the UDP table: prefer the
|
||||
// configured hostname over the resolved address, and keep the two rows of a
|
||||
// dual-stack server distinguishable.
|
||||
func TestUDPProbeLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in netdiag.UDPProbe
|
||||
want string
|
||||
}{
|
||||
{"resolved v4", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米"},
|
||||
"小米 stun.miwifi.com:3478 · IPv4"},
|
||||
{"resolved v6", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米"},
|
||||
"小米 stun.miwifi.com:3478 · IPv6"},
|
||||
{"dns failure keeps the hostname", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478", Name: "X"},
|
||||
"X a.example:3478"},
|
||||
{"no name", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478"}, "a.example:3478"},
|
||||
{"no host falls back to target", netdiag.UDPProbe{Target: "1.2.3.4:3478"}, "1.2.3.4:3478 · IPv4"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := udpProbeLabel(tc.in); got != tc.want {
|
||||
t.Errorf("udpProbeLabel() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+62
-7
@@ -221,19 +221,74 @@ func LoadCJKFaces(path string, logger *slog.Logger) ([]font.FontFace, error) {
|
||||
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]
|
||||
if len(faces) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping
|
||||
// one avoids paying for five near-identical fallbacks on every glyph miss;
|
||||
// pickCJKFace decides which one.
|
||||
base := faces[pickCJKFace(faces)]
|
||||
out := cjkWeightVariants(base)
|
||||
logger.Debug("cjk font loaded",
|
||||
"path", path,
|
||||
"faces", len(faces),
|
||||
"typeface", string(base.Font.Typeface),
|
||||
"faces", len(out),
|
||||
"bytes", st.Size(),
|
||||
"took", time.Since(start).Round(time.Millisecond),
|
||||
)
|
||||
return faces, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scWeights are the family-name markers of the Simplified Chinese cut, in
|
||||
// preference order. Pan-CJK collections order their faces JP first, so taking
|
||||
// faces[0] blindly renders Han characters with Japanese glyph variants — legible,
|
||||
// but visibly wrong to a Chinese reader.
|
||||
var scMarkers = []string{"sc", "simplified", "cn", "hans"}
|
||||
|
||||
// pickCJKFace returns the index of the face to use, preferring the Simplified
|
||||
// Chinese cut and falling back to the first face.
|
||||
func pickCJKFace(faces []font.FontFace) int {
|
||||
for _, marker := range scMarkers {
|
||||
for i, f := range faces {
|
||||
name := strings.ToLower(string(f.Font.Typeface))
|
||||
// Match on a word/suffix boundary so "sc" does not hit "Sans".
|
||||
for _, field := range strings.FieldsFunc(name, func(r rune) bool {
|
||||
return r == ' ' || r == '-' || r == '_'
|
||||
}) {
|
||||
if field == marker || strings.HasSuffix(field, marker) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// cjkWeightVariants registers one parsed face under every weight the UI asks
|
||||
// for.
|
||||
//
|
||||
// This exists because of how Gio resolves fonts. The theme pins every label's
|
||||
// Typeface to "Go" (see NewTheme), and Gio never tells go-text which script it
|
||||
// is shaping, so our explicitly-loaded CJK font is only reachable through
|
||||
// fontscan's user-provided tier — which prunes candidates by weight before
|
||||
// checking coverage. A face registered only at Normal is therefore invisible to
|
||||
// any label that sets Font.Weight, and every section title, card header and
|
||||
// button does exactly that. The result was Chinese body text rendering fine
|
||||
// while every heading turned into tofu boxes.
|
||||
//
|
||||
// The variants share the same underlying Face, so CJK headings are not visually
|
||||
// bolder than body text. That is a deliberate trade: identical weight beats
|
||||
// missing glyphs, and synthetic emboldening is not available here.
|
||||
func cjkWeightVariants(base font.FontFace) []font.FontFace {
|
||||
weights := []font.Weight{font.Normal, font.Medium, font.SemiBold, font.Bold}
|
||||
out := make([]font.FontFace, 0, len(weights))
|
||||
for _, w := range weights {
|
||||
f := base.Font
|
||||
f.Weight = w
|
||||
f.Style = font.Regular
|
||||
out = append(out, font.FontFace{Font: f, Face: base.Face})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// goCollection returns the built-in Go font faces. It exists so tests can
|
||||
|
||||
+47
-74
@@ -28,7 +28,6 @@ const (
|
||||
// Navigation.
|
||||
KNavOverview
|
||||
KNavPeers
|
||||
KNavLan
|
||||
KNavDiag
|
||||
KNavLogs
|
||||
KNavSettings
|
||||
@@ -51,7 +50,8 @@ const (
|
||||
KStepMonitors
|
||||
KStepReady
|
||||
KSplashHint
|
||||
KSplashLogHint
|
||||
KSplashStuckHint
|
||||
KSplashExportLog
|
||||
KSplashRetry
|
||||
|
||||
// Shared vocabulary.
|
||||
@@ -84,7 +84,6 @@ const (
|
||||
KOvTailnet
|
||||
KOvSelf
|
||||
KOvPeersOnline
|
||||
KOvLanServers
|
||||
KOvForwardRules
|
||||
KOvConnectRules
|
||||
KOvUptime
|
||||
@@ -95,8 +94,8 @@ const (
|
||||
// Peers page.
|
||||
KPeersTitle
|
||||
KPeersLinked
|
||||
KPeersOther
|
||||
KPeersEmpty
|
||||
KPeersResolving
|
||||
KPeerLatency
|
||||
KPeerRoute
|
||||
KPeerRouteDirect
|
||||
@@ -123,19 +122,11 @@ const (
|
||||
KGraphWindow
|
||||
KGraphLegendHint
|
||||
|
||||
// LAN page.
|
||||
KLanTitle
|
||||
KLanSubtitle
|
||||
KLanEmpty
|
||||
KLanListening
|
||||
KLanMotd
|
||||
KLanPort
|
||||
KLanAddress
|
||||
KLanSeen
|
||||
KLanSelf
|
||||
KLanSelfHint
|
||||
KLanPackets
|
||||
KLanBindError
|
||||
// Local services (overview).
|
||||
KSvcTitle
|
||||
KSvcSubtitle
|
||||
KSvcEmpty
|
||||
KSvcBroadcast
|
||||
|
||||
// Diagnostics page.
|
||||
KDiagTitle
|
||||
@@ -174,6 +165,7 @@ const (
|
||||
KDiagEgressGeo
|
||||
KDiagEgressDivergent
|
||||
KDiagEgressDivergentHint
|
||||
KDiagEgressDivergentHTTP
|
||||
KDiagGeoSkipped
|
||||
KDiagPreferredDERP
|
||||
KDiagDerpLatency
|
||||
@@ -211,7 +203,6 @@ const (
|
||||
KLogsShown
|
||||
KLogsDropped
|
||||
KLogsIncludeDiag
|
||||
KLogsOpenOverlay
|
||||
|
||||
// Settings.
|
||||
KSetTheme
|
||||
@@ -233,7 +224,6 @@ var zhStrings = [kCount]string{
|
||||
|
||||
KNavOverview: "概览",
|
||||
KNavPeers: "节点",
|
||||
KNavLan: "局域网",
|
||||
KNavDiag: "网络诊断",
|
||||
KNavLogs: "日志",
|
||||
KNavSettings: "设置",
|
||||
@@ -246,16 +236,17 @@ var zhStrings = [kCount]string{
|
||||
KStateError: "出错",
|
||||
KStateRetrying: "正在重试",
|
||||
|
||||
KStepConfig: "读取配置",
|
||||
KStepFonts: "加载字体",
|
||||
KStepTsnet: "接入 Tailscale 网络",
|
||||
KStepRules: "解析转发规则",
|
||||
KStepDiscovery: "启动局域网发现",
|
||||
KStepMonitors: "启动状态监控",
|
||||
KStepReady: "准备就绪",
|
||||
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
|
||||
KSplashLogHint: "实时日志(截图时可一并保留)",
|
||||
KSplashRetry: "启动失败,正在重试",
|
||||
KStepConfig: "读取配置",
|
||||
KStepFonts: "加载字体",
|
||||
KStepTsnet: "接入 Tailscale 网络",
|
||||
KStepRules: "解析转发规则",
|
||||
KStepDiscovery: "启动局域网发现",
|
||||
KStepMonitors: "启动状态监控",
|
||||
KStepReady: "准备就绪",
|
||||
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
|
||||
KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查",
|
||||
KSplashExportLog: "导出日志",
|
||||
KSplashRetry: "启动失败,正在重试",
|
||||
|
||||
KYes: "是",
|
||||
KNo: "否",
|
||||
@@ -285,7 +276,6 @@ var zhStrings = [kCount]string{
|
||||
KOvTailnet: "Tailnet",
|
||||
KOvSelf: "本机",
|
||||
KOvPeersOnline: "在线节点",
|
||||
KOvLanServers: "局域网服务器",
|
||||
KOvForwardRules: "转发规则",
|
||||
KOvConnectRules: "连接规则",
|
||||
KOvUptime: "运行时长",
|
||||
@@ -295,8 +285,8 @@ var zhStrings = [kCount]string{
|
||||
|
||||
KPeersTitle: "Tailscale 节点",
|
||||
KPeersLinked: "已关联",
|
||||
KPeersOther: "其他节点",
|
||||
KPeersEmpty: "暂无节点",
|
||||
KPeersResolving: "正在解析配置中的节点",
|
||||
KPeerLatency: "延迟",
|
||||
KPeerRoute: "链路",
|
||||
KPeerRouteDirect: "直连",
|
||||
@@ -320,21 +310,13 @@ var zhStrings = [kCount]string{
|
||||
KPeerTags: "标签",
|
||||
KGraphTitle: "延迟图谱",
|
||||
KGraphEmpty: "正在采集延迟数据",
|
||||
KGraphWindow: "最近 20 分钟",
|
||||
KGraphWindow: "最近",
|
||||
KGraphLegendHint: "点击图例可隐藏对应节点",
|
||||
|
||||
KLanTitle: "局域网 Minecraft 服务器",
|
||||
KLanSubtitle: "监听 224.0.2.60:4445 的广播",
|
||||
KLanEmpty: "未发现局域网服务器",
|
||||
KLanListening: "监听中",
|
||||
KLanMotd: "服务器名称",
|
||||
KLanPort: "端口",
|
||||
KLanAddress: "地址",
|
||||
KLanSeen: "最后广播",
|
||||
KLanSelf: "本机广播",
|
||||
KLanSelfHint: "由 tslink 转发并广播,说明隧道已生效",
|
||||
KLanPackets: "收包",
|
||||
KLanBindError: "无法监听组播",
|
||||
KSvcTitle: "本机服务",
|
||||
KSvcSubtitle: "tslink 在本机监听并转发到对应服务器",
|
||||
KSvcEmpty: "配置中没有连接规则",
|
||||
KSvcBroadcast: "已广播",
|
||||
|
||||
KDiagTitle: "网络诊断",
|
||||
KDiagRun: "开始诊断",
|
||||
@@ -371,7 +353,8 @@ var zhStrings = [kCount]string{
|
||||
KDiagEgressIP: "出口 IP",
|
||||
KDiagEgressGeo: "归属地",
|
||||
KDiagEgressDivergent: "出口不一致",
|
||||
KDiagEgressDivergentHint: "不同探测方式得到了不同的公网 IP,通常说明有代理或分流工具在生效",
|
||||
KDiagEgressDivergentHint: "STUN(UDP)本身就看到多个公网 IP,直连打洞会受影响",
|
||||
KDiagEgressDivergentHTTP: "仅 HTTP 探测看到不同的公网 IP,STUN(UDP)出口一致,通常不影响打洞",
|
||||
KDiagGeoSkipped: "已跳过归属地查询",
|
||||
KDiagPreferredDERP: "首选 DERP",
|
||||
KDiagDerpLatency: "DERP 延迟",
|
||||
@@ -407,7 +390,6 @@ var zhStrings = [kCount]string{
|
||||
KLogsShown: "已显示",
|
||||
KLogsDropped: "条早期日志已被丢弃",
|
||||
KLogsIncludeDiag: "附带诊断报告",
|
||||
KLogsOpenOverlay: "浮层日志",
|
||||
|
||||
KSetTheme: "主题",
|
||||
KSetThemeDark: "深色",
|
||||
@@ -426,7 +408,6 @@ var enStrings = [kCount]string{
|
||||
|
||||
KNavOverview: "Overview",
|
||||
KNavPeers: "Peers",
|
||||
KNavLan: "LAN",
|
||||
KNavDiag: "Diagnostics",
|
||||
KNavLogs: "Logs",
|
||||
KNavSettings: "Settings",
|
||||
@@ -439,16 +420,17 @@ var enStrings = [kCount]string{
|
||||
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",
|
||||
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",
|
||||
KSplashStuckHint: "This step is taking unusually long — export the log to investigate",
|
||||
KSplashExportLog: "Export log",
|
||||
KSplashRetry: "Startup failed, retrying",
|
||||
|
||||
KYes: "Yes",
|
||||
KNo: "No",
|
||||
@@ -478,7 +460,6 @@ var enStrings = [kCount]string{
|
||||
KOvTailnet: "Tailnet",
|
||||
KOvSelf: "This node",
|
||||
KOvPeersOnline: "Peers online",
|
||||
KOvLanServers: "LAN servers",
|
||||
KOvForwardRules: "Forward rules",
|
||||
KOvConnectRules: "Connect rules",
|
||||
KOvUptime: "Uptime",
|
||||
@@ -488,8 +469,8 @@ var enStrings = [kCount]string{
|
||||
|
||||
KPeersTitle: "Tailscale peers",
|
||||
KPeersLinked: "Linked",
|
||||
KPeersOther: "Other peers",
|
||||
KPeersEmpty: "No peers yet",
|
||||
KPeersResolving: "Resolving the peers named in the config",
|
||||
KPeerLatency: "Latency",
|
||||
KPeerRoute: "Route",
|
||||
KPeerRouteDirect: "Direct",
|
||||
@@ -513,21 +494,13 @@ var enStrings = [kCount]string{
|
||||
KPeerTags: "Tags",
|
||||
KGraphTitle: "Latency graph",
|
||||
KGraphEmpty: "Collecting latency samples",
|
||||
KGraphWindow: "last 20 minutes",
|
||||
KGraphWindow: "last",
|
||||
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",
|
||||
KSvcTitle: "Local services",
|
||||
KSvcSubtitle: "Listening on this machine, forwarded to each server",
|
||||
KSvcEmpty: "No connect rules configured",
|
||||
KSvcBroadcast: "Broadcast",
|
||||
|
||||
KDiagTitle: "Network diagnostics",
|
||||
KDiagRun: "Run diagnostics",
|
||||
@@ -564,7 +537,8 @@ var enStrings = [kCount]string{
|
||||
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",
|
||||
KDiagEgressDivergentHint: "STUN (UDP) itself saw more than one public IP, so direct connections will suffer",
|
||||
KDiagEgressDivergentHTTP: "Only the HTTP probes disagreed; the STUN (UDP) egress is consistent, so hole punching is usually unaffected",
|
||||
KDiagGeoSkipped: "Geolocation skipped",
|
||||
KDiagPreferredDERP: "Preferred DERP",
|
||||
KDiagDerpLatency: "DERP latency",
|
||||
@@ -600,7 +574,6 @@ var enStrings = [kCount]string{
|
||||
KLogsShown: "shown",
|
||||
KLogsDropped: "earlier entries were dropped",
|
||||
KLogsIncludeDiag: "Include diagnostics",
|
||||
KLogsOpenOverlay: "Log overlay",
|
||||
|
||||
KSetTheme: "Theme",
|
||||
KSetThemeDark: "Dark",
|
||||
|
||||
@@ -257,14 +257,6 @@ func IconWarn(gtx C, size int, col color.NRGBA) D {
|
||||
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) {
|
||||
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
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()
|
||||
}
|
||||
+177
-10
@@ -2,6 +2,7 @@ package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -188,7 +189,10 @@ func (p *diagPage) controlCard(a *App, gtx C, running bool, rep *netdiag.Report,
|
||||
col = th.P.TextPri
|
||||
} else if rep != nil {
|
||||
headline = rep.Headline
|
||||
col = th.StatusColor(diagLevel(rep.Status))
|
||||
// The headline's own severity, not the report's: an
|
||||
// unrelated failure elsewhere must not paint a merely
|
||||
// cautionary sentence in alarm red.
|
||||
col = th.StatusColor(diagLevel(rep.HeadlineStatus))
|
||||
}
|
||||
l := th.Text(SizeSubtitle, col, headline)
|
||||
l.Font.Weight = font.SemiBold
|
||||
@@ -393,6 +397,132 @@ func (p *diagPage) triLabel(th *Theme, v *bool) (string, StatusLevel) {
|
||||
return th.T(KNo), LevelWarn
|
||||
}
|
||||
|
||||
// A bare "unknown" or "no" in the results tells the user what was measured but
|
||||
// not what it costs them. These helpers add the one-line consequence, which is
|
||||
// the part that actually answers "should I care".
|
||||
//
|
||||
// They follow behaviorLabel's inline bilingual switch rather than i18n keys:
|
||||
// the strings are explanatory prose, only ever used here.
|
||||
|
||||
// behaviorHint explains an RFC 5780 mapping/filtering behaviour.
|
||||
func behaviorHint(th *Theme, b netdiag.Behavior) string {
|
||||
zh := th.Lang == LangZH
|
||||
switch b {
|
||||
case netdiag.BehaviorEndpointIndependent:
|
||||
if zh {
|
||||
return "对所有目标复用同一个外部端口,最利于打洞"
|
||||
}
|
||||
return "one external port for every destination — best case for hole punching"
|
||||
case netdiag.BehaviorAddressDependent:
|
||||
if zh {
|
||||
return "换一个目标地址就换一个映射"
|
||||
}
|
||||
return "the mapping changes with the destination address"
|
||||
case netdiag.BehaviorAddressAndPortDependent:
|
||||
if zh {
|
||||
return "目标地址或端口一变映射就变,等同对称型"
|
||||
}
|
||||
return "the mapping changes with address or port — effectively symmetric"
|
||||
default:
|
||||
if zh {
|
||||
return "没有服务器支持 CHANGE-REQUEST,无法判定"
|
||||
}
|
||||
return "no server supported CHANGE-REQUEST, so this could not be determined"
|
||||
}
|
||||
}
|
||||
|
||||
// hairpinHint explains whether the NAT loops traffic sent to its own external
|
||||
// address back inside.
|
||||
func hairpinHint(th *Theme, v *bool) string {
|
||||
zh := th.Lang == LangZH
|
||||
switch {
|
||||
case v == nil:
|
||||
if zh {
|
||||
return "未测试"
|
||||
}
|
||||
return "not tested"
|
||||
case *v:
|
||||
if zh {
|
||||
return "同一内网的两台机器可经外网地址互连"
|
||||
}
|
||||
return "two machines behind this NAT can reach each other via the external address"
|
||||
default:
|
||||
if zh {
|
||||
return "同一内网内无法经外网地址回环,需走内网地址"
|
||||
}
|
||||
return "traffic to the external address does not loop back; use the LAN address instead"
|
||||
}
|
||||
}
|
||||
|
||||
// preserveHint explains whether the external port matches the local one.
|
||||
func preserveHint(th *Theme, v *bool) string {
|
||||
zh := th.Lang == LangZH
|
||||
switch {
|
||||
case v == nil:
|
||||
if zh {
|
||||
return "未测试"
|
||||
}
|
||||
return "not tested"
|
||||
case *v:
|
||||
if zh {
|
||||
return "外部端口与本地端口一致,对端更容易预测"
|
||||
}
|
||||
return "the external port matches the local one, so peers can predict it"
|
||||
default:
|
||||
if zh {
|
||||
return "外部端口被改写,端口预测不可靠"
|
||||
}
|
||||
return "the external port is rewritten, so port prediction is unreliable"
|
||||
}
|
||||
}
|
||||
|
||||
// reachHint labels the CN/international pair, which is otherwise four bare
|
||||
// numbers with no indication of what they count.
|
||||
func reachHint(th *Theme) string {
|
||||
if th.Lang == LangZH {
|
||||
return "各自可达 / 探测总数"
|
||||
}
|
||||
return "reachable / probed, per region"
|
||||
}
|
||||
|
||||
// udpFamilyStats counts responding and probed servers per address family.
|
||||
// Probes whose DNS lookup failed carry no address and belong to neither.
|
||||
func udpFamilyStats(r netdiag.UDPReport) (v4ok, v4n, v6ok, v6n int) {
|
||||
for _, p := range r.Probes {
|
||||
ap, err := netip.ParseAddrPort(p.Target)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ap.Addr().Is4() || ap.Addr().Is4In6() {
|
||||
v4n++
|
||||
if p.OK {
|
||||
v4ok++
|
||||
}
|
||||
continue
|
||||
}
|
||||
v6n++
|
||||
if p.OK {
|
||||
v6ok++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// udpFamilyHint reports how many servers answered on one address family.
|
||||
func udpFamilyHint(th *Theme, ok, total int) string {
|
||||
zh := th.Lang == LangZH
|
||||
if total == 0 {
|
||||
if zh {
|
||||
return "没有可探测的地址"
|
||||
}
|
||||
return "no address to probe"
|
||||
}
|
||||
if zh {
|
||||
return itoa(ok) + "/" + itoa(total) + " 台服务器响应"
|
||||
}
|
||||
return itoa(ok) + "/" + itoa(total) + " servers responded"
|
||||
}
|
||||
|
||||
func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D {
|
||||
th := a.th
|
||||
hairpin, hairpinLvl := p.triLabel(th, r.Hairpin)
|
||||
@@ -411,10 +541,10 @@ func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D {
|
||||
}),
|
||||
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},
|
||||
{Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping), Hint: behaviorHint(th, r.Mapping)},
|
||||
{Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering), Hint: behaviorHint(th, r.Filtering)},
|
||||
{Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl, Hint: hairpinHint(th, r.Hairpin)},
|
||||
{Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl, Hint: preserveHint(th, r.PortPreserving)},
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
@@ -493,6 +623,35 @@ func (p *diagPage) stunTable(a *App, gtx C, results []netdiag.STUNResult) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
|
||||
// udpProbeLabel names a UDP probe the way the STUN table names its rows: the
|
||||
// operator, then the hostname the user configured.
|
||||
//
|
||||
// The raw resolved address is not a useful label — nobody recognises
|
||||
// 111.206.174.2:3478 as 小米 — but it is the only thing distinguishing the two
|
||||
// rows a dual-stack server produces, so the family is appended instead.
|
||||
func udpProbeLabel(pr netdiag.UDPProbe) string {
|
||||
host := pr.Host
|
||||
if host == "" {
|
||||
host = pr.Target
|
||||
}
|
||||
label := host
|
||||
if pr.Name != "" {
|
||||
label = pr.Name + " " + host
|
||||
}
|
||||
// Only meaningful when Target is a resolved address rather than a copy of
|
||||
// Host, which is what the DNS-failure path stores.
|
||||
if pr.Target != "" && pr.Target != pr.Host {
|
||||
if ap, err := netip.ParseAddrPort(pr.Target); err == nil {
|
||||
if ap.Addr().Is4() || ap.Addr().Is4In6() {
|
||||
label += " · IPv4"
|
||||
} else {
|
||||
label += " · IPv6"
|
||||
}
|
||||
}
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -560,15 +719,18 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D {
|
||||
v6lvl = LevelNeutral
|
||||
}
|
||||
|
||||
v4ok, v4n, v6ok, v6n := udpFamilyStats(r)
|
||||
|
||||
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: th.T(KDiagUdpV4), Value: v4, Level: v4lvl, Hint: udpFamilyHint(th, v4ok, v4n)},
|
||||
{Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl, Hint: udpFamilyHint(th, v6ok, v6n)},
|
||||
{
|
||||
Key: "国内 / 境外",
|
||||
Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " +
|
||||
itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal),
|
||||
Mono: true,
|
||||
Hint: reachHint(th),
|
||||
},
|
||||
}
|
||||
if th.Lang != LangZH {
|
||||
@@ -600,7 +762,7 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D {
|
||||
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 p.tableRow(a, gtx, regionTag(th, pr.Region)+udpProbeLabel(pr), val, rtt, level)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
@@ -704,9 +866,14 @@ func (p *diagPage) egressCard(a *App, gtx C, r netdiag.EgressReport) D {
|
||||
if !r.Divergent {
|
||||
return D{}
|
||||
}
|
||||
// Only a split seen by STUN itself threatens the UDP path, so
|
||||
// only that one gets the red treatment.
|
||||
level, hint := LevelWarn, th.T(KDiagEgressDivergentHTTP)
|
||||
if r.DivergentSTUN {
|
||||
level, hint = LevelFail, th.T(KDiagEgressDivergentHint)
|
||||
}
|
||||
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return p.callout(a, gtx, LevelWarn,
|
||||
th.T(KDiagEgressDivergent), th.T(KDiagEgressDivergentHint))
|
||||
return p.callout(a, gtx, level, th.T(KDiagEgressDivergent), hint)
|
||||
})
|
||||
}),
|
||||
// Geolocation first: "where do I appear to be" is the question, the
|
||||
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -153,6 +153,7 @@ func (p *logsPage) handleActions(a *App, gtx C, buf *core.LogBuffer) {
|
||||
a.notify(th.T(KError)+": "+err.Error(), LevelFail)
|
||||
} else {
|
||||
a.notify(path, LevelOK)
|
||||
a.reveal(path)
|
||||
}
|
||||
}
|
||||
if p.uploadBtn.Clicked(gtx) {
|
||||
|
||||
+33
-18
@@ -1,11 +1,13 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
@@ -18,6 +20,8 @@ type overviewPage struct {
|
||||
diagBtn widget.Clickable
|
||||
peersBtn widget.Clickable
|
||||
copySelf widget.Clickable
|
||||
// svcCopy holds one clickable per service address, allocated on demand.
|
||||
svcCopy map[string]*widget.Clickable
|
||||
}
|
||||
|
||||
func newOverviewPage() *overviewPage {
|
||||
@@ -41,19 +45,17 @@ func (p *overviewPage) Layout(a *App, gtx C, st core.State) D {
|
||||
if st.Peers != nil {
|
||||
snap = st.Peers.Snapshot()
|
||||
}
|
||||
var lanServers []core.LanServer
|
||||
if st.Lan != nil {
|
||||
lanServers = st.Lan.Servers()
|
||||
}
|
||||
servers := buildServices(st.Config, snap)
|
||||
|
||||
if p.copySelf.Clicked(gtx) {
|
||||
a.copyToClipboard(gtx, selfAddrText(snap.Self), "")
|
||||
}
|
||||
|
||||
items := []layout.Widget{
|
||||
func(gtx C) D { return p.statRow(a, gtx, st, snap, lanServers) },
|
||||
func(gtx C) D { return p.statRow(a, gtx, st, snap, servers) },
|
||||
func(gtx C) D { return p.healthCard(a, gtx) },
|
||||
func(gtx C) D { return p.selfCard(a, gtx, st, snap) },
|
||||
func(gtx C) D { return p.servicesCard(a, gtx, servers) },
|
||||
func(gtx C) D { return p.linkedCard(a, gtx, snap) },
|
||||
}
|
||||
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
|
||||
@@ -88,19 +90,29 @@ func (p *overviewPage) statTile(a *App, gtx C, value, label, hint string, level
|
||||
if level != LevelNeutral {
|
||||
l.Color = th.StatusColor(level)
|
||||
}
|
||||
return l.Layout(gtx)
|
||||
// Single line, always. A value like "19 / 25" wraps at narrow
|
||||
// tile widths where "8" does not, and one tile a whole line
|
||||
// taller than its neighbours is what makes the row look broken.
|
||||
return OneLine(l).Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if hint == "" {
|
||||
return D{}
|
||||
if hint != "" {
|
||||
return OneLine(th.Caption(hint)).Layout(gtx)
|
||||
}
|
||||
return OneLine(th.Caption(hint)).Layout(gtx)
|
||||
// Reserve the hint line even when there is no hint. These tiles
|
||||
// sit in a row, and Flex does not equalise child heights, so a
|
||||
// tile that skipped this line came out shorter than its
|
||||
// neighbours and the row looked misaligned.
|
||||
macro := op.Record(gtx.Ops)
|
||||
d := OneLine(th.Caption("X")).Layout(gtx)
|
||||
macro.Stop()
|
||||
return D{Size: image.Pt(0, d.Size.Y)}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, lan []core.LanServer) D {
|
||||
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, servers []serviceServer) D {
|
||||
th := a.th
|
||||
|
||||
online, linked := 0, 0
|
||||
@@ -121,10 +133,13 @@ func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnaps
|
||||
connectRules += len(rs)
|
||||
}
|
||||
}
|
||||
selfLan := 0
|
||||
for _, s := range lan {
|
||||
if s.IsSelf {
|
||||
selfLan++
|
||||
services, broadcast := 0, 0
|
||||
for _, srv := range servers {
|
||||
services += len(srv.Services)
|
||||
for _, svc := range srv.Services {
|
||||
if svc.Broadcast {
|
||||
broadcast++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +163,9 @@ func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnaps
|
||||
},
|
||||
func(gtx C) D {
|
||||
return p.statTile(a, gtx,
|
||||
itoa(len(lan)),
|
||||
th.T(KOvLanServers),
|
||||
itoa(selfLan)+" "+th.T(KLanSelf),
|
||||
itoa(services),
|
||||
th.T(KSvcTitle),
|
||||
itoa(broadcast)+" "+th.T(KSvcBroadcast),
|
||||
LevelNeutral, IconServer)
|
||||
},
|
||||
func(gtx C) D {
|
||||
@@ -202,7 +217,7 @@ func (p *overviewPage) healthCard(a *App, gtx C) D {
|
||||
}
|
||||
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 := th.Text(SizeBody, th.StatusColor(diagLevel(rep.HeadlineStatus)), rep.Headline)
|
||||
l.Font.Weight = font.Medium
|
||||
l.MaxLines = 2
|
||||
return l.Layout(gtx)
|
||||
|
||||
+62
-41
@@ -12,9 +12,12 @@ import (
|
||||
"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
|
||||
// chartWindow is the most latency history the graph shows. The monitor retains
|
||||
// 20 minutes, but a spike that old tells you nothing about the session you are
|
||||
// in right now, and stretching the axis over it flattens everything recent into
|
||||
// noise. The axis scales to whatever data exists within this bound, so the plot
|
||||
// is full from the second sample rather than after 20 minutes of uptime.
|
||||
const chartWindow = 3 * 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
|
||||
@@ -75,8 +78,10 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
|
||||
a.notify(th.T(KRefresh), LevelInfo)
|
||||
}
|
||||
|
||||
linked, other := splitPeers(snap.Peers)
|
||||
series := p.buildSeries(th, snap.Peers)
|
||||
// Only nodes a config rule points at. The netmap contains every machine on
|
||||
// the tailnet, most of which the user has no rule for and no interest in.
|
||||
linked, _ := splitPeers(snap.Peers)
|
||||
series := p.buildSeries(th, linked)
|
||||
|
||||
// Legend clicks toggle series visibility.
|
||||
for i := range series {
|
||||
@@ -87,7 +92,7 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
|
||||
series[i].s.Hidden = p.hidden[id]
|
||||
}
|
||||
|
||||
items := make([]layout.Widget, 0, len(snap.Peers)+4)
|
||||
items := make([]layout.Widget, 0, len(linked)+4)
|
||||
items = append(items, func(gtx C) D { return p.chartCard(a, gtx, series) })
|
||||
|
||||
if len(linked) > 0 {
|
||||
@@ -97,22 +102,21 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
|
||||
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 {
|
||||
} else {
|
||||
items = append(items, func(gtx C) D {
|
||||
// Link resolution is periodic and needs DNS, so on a fresh boot
|
||||
// every peer is briefly unlinked. Saying "no peers" there would be
|
||||
// wrong; the netmap may be full of machines we simply have no rule
|
||||
// for yet.
|
||||
hint := snap.Err
|
||||
if hint == "" {
|
||||
hint = snap.BackendState
|
||||
}
|
||||
return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), hint)
|
||||
title := th.T(KPeersEmpty)
|
||||
if len(snap.Peers) > 0 {
|
||||
title = th.T(KPeersResolving)
|
||||
}
|
||||
return th.EmptyState(gtx, IconNodes, title, hint)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,14 +160,24 @@ func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries
|
||||
if len(pr.Samples) == 0 {
|
||||
continue
|
||||
}
|
||||
// Drop samples outside the window by age rather than by count: the
|
||||
// monitor's ring is not evenly spaced, because a manual refresh injects
|
||||
// an off-cycle sweep.
|
||||
cutoff := time.Now().Add(-chartWindow)
|
||||
pts := make([]ChartPoint, 0, len(pr.Samples))
|
||||
for _, s := range pr.Samples {
|
||||
if s.At.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
pts = append(pts, ChartPoint{
|
||||
At: s.At,
|
||||
Value: float64(s.Latency) / float64(time.Millisecond),
|
||||
OK: s.OK,
|
||||
})
|
||||
}
|
||||
if len(pts) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, namedSeries{
|
||||
id: pr.ID,
|
||||
s: ChartSeries{
|
||||
@@ -179,9 +193,26 @@ func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries
|
||||
|
||||
func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
|
||||
th := a.th
|
||||
plot := make([]ChartSeries, len(series))
|
||||
for i, s := range series {
|
||||
plot[i] = s.s
|
||||
}
|
||||
style := ChartStyle{
|
||||
Height: 200,
|
||||
MaxWindow: chartWindow,
|
||||
Now: time.Now(),
|
||||
Unit: "ms",
|
||||
FillSingle: true,
|
||||
}
|
||||
|
||||
card := th.Card()
|
||||
card.Title = th.T(KGraphTitle)
|
||||
card.Subtitle = th.T(KGraphWindow)
|
||||
// The axis follows the data, so the subtitle has to as well — a fixed
|
||||
// "last 20 minutes" was a lie for the first 20 minutes of every run.
|
||||
if len(plot) > 0 {
|
||||
tMin, tMax := domain(plot, style)
|
||||
card.Subtitle = th.T(KGraphWindow) + " " + FormatDuration(tMax.Sub(tMin))
|
||||
}
|
||||
card.Trailing = func(gtx C) D {
|
||||
return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral)
|
||||
}
|
||||
@@ -189,19 +220,9 @@ func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) 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)
|
||||
return p.chart.Layout(th, gtx, style, plot)
|
||||
}),
|
||||
VGap(SpaceMD),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
@@ -213,23 +234,23 @@ func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
|
||||
|
||||
func (p *peersPage) legendRow(a *App, gtx C, series []namedSeries) D {
|
||||
th := a.th
|
||||
children := make([]layout.FlexChild, 0, len(series))
|
||||
entries := make([]LegendEntry, 0, len(series))
|
||||
for _, s := range series {
|
||||
id := s.id
|
||||
entry := LegendEntry{
|
||||
entries = append(entries, LegendEntry{
|
||||
Name: s.s.Name,
|
||||
Color: s.s.Color,
|
||||
Hidden: p.hidden[id],
|
||||
Hidden: p.hidden[s.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...)
|
||||
return th.Legend(gtx, entries, func(i int) layout.Widget {
|
||||
click := p.legendClick(series[i].id)
|
||||
return func(gtx C) D {
|
||||
return click.Layout(gtx, func(gtx C) D {
|
||||
return th.LegendChip(gtx, entries[i], click.Hovered())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lastValue(points []ChartPoint) string {
|
||||
|
||||
+82
-16
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
@@ -95,7 +96,6 @@ func readyState(t *testing.T) core.State {
|
||||
ReadyAt: time.Now().Add(-time.Hour),
|
||||
Config: cfg,
|
||||
Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}),
|
||||
Lan: core.NewLanScanner(logger),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestPagesLayout(t *testing.T) {
|
||||
{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}
|
||||
pages := []pageID{pageOverview, pagePeers, pageDiag, pageLogs, pageSettings}
|
||||
|
||||
for _, size := range sizes {
|
||||
for _, page := range pages {
|
||||
@@ -138,13 +138,32 @@ func TestSplashLayout(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplashStuck covers the >20s branch, which swaps the footer hint and
|
||||
// promotes the export button.
|
||||
func TestSplashStuck(t *testing.T) {
|
||||
a := testApp(t)
|
||||
steps := splashTestSteps()
|
||||
for i := range steps {
|
||||
if steps[i].State == core.StepRunning {
|
||||
steps[i].Started = time.Now().Add(-45 * time.Second)
|
||||
}
|
||||
}
|
||||
st := core.State{
|
||||
Phase: core.PhaseStarting,
|
||||
Steps: steps,
|
||||
StartedAt: time.Now().Add(-45 * time.Second),
|
||||
}
|
||||
if !stalled(st) {
|
||||
t.Fatal("stalled() should report a step running past stuckAfter")
|
||||
}
|
||||
gtx, _ := newTestContext(image.Pt(460, 450))
|
||||
a.splash.Layout(a, gtx, st)
|
||||
}
|
||||
|
||||
func splashTestSteps() []core.BootStep {
|
||||
now := time.Now()
|
||||
return []core.BootStep{
|
||||
@@ -184,8 +203,12 @@ func TestDiagPageWithReport(t *testing.T) {
|
||||
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"},
|
||||
// A resolved probe: Host names the server, Target is the
|
||||
// address actually hit, and the label must show the former.
|
||||
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
|
||||
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond, Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
|
||||
// DNS failed, so Target still holds the hostname.
|
||||
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google", Region: netdiag.RegionIntl, Err: "i/o timeout"},
|
||||
},
|
||||
},
|
||||
NAT: netdiag.NATReport{
|
||||
@@ -253,15 +276,6 @@ func TestDiagPageWithReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -306,3 +320,55 @@ func TestTrFallsBackToEnglish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrowOnce guards the window-resize latch. Growing more than once would
|
||||
// snap a window the user had deliberately resized back to the shell default
|
||||
// every time the service restarted.
|
||||
func TestGrowOnce(t *testing.T) {
|
||||
a := testApp(t)
|
||||
// A Window with no driver queues options instead of touching a display,
|
||||
// which is what makes this testable without one.
|
||||
w := new(app.Window)
|
||||
|
||||
if a.grown.Load() {
|
||||
t.Fatal("a fresh App must not be marked grown")
|
||||
}
|
||||
a.grow(w)
|
||||
if !a.grown.Load() {
|
||||
t.Fatal("grow() must latch")
|
||||
}
|
||||
// Must be a no-op now.
|
||||
a.grow(w)
|
||||
a.grow(w)
|
||||
}
|
||||
|
||||
// TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a
|
||||
// Flex, which does not equalise child heights, so anything that makes one tile
|
||||
// taller — a wrapped value, a hint line present on some tiles but not others —
|
||||
// visibly misaligns the row. Narrow widths are the interesting case: that is
|
||||
// where "19 / 25" wraps and "8" does not.
|
||||
func TestStatTilesUniformHeight(t *testing.T) {
|
||||
a := testApp(t)
|
||||
tiles := []struct{ value, label, hint string }{
|
||||
{"19 / 25", "在线节点", "3 已关联"},
|
||||
{"8", "本机服务", "5 已广播"},
|
||||
{"8 / 0", "连接规则 / 转发规则", ""},
|
||||
{"10s", "运行时长", ""},
|
||||
}
|
||||
for _, w := range []int{60, 80, 100, 140, 200, 300} {
|
||||
var first int
|
||||
for i, c := range tiles {
|
||||
gtx, _ := newTestContext(image.Pt(w, 400))
|
||||
gtx.Constraints.Min = image.Point{}
|
||||
h := a.overview.statTile(a, gtx, c.value, c.label, c.hint, LevelNeutral, IconNodes).Size.Y
|
||||
if i == 0 {
|
||||
first = h
|
||||
continue
|
||||
}
|
||||
if h != first {
|
||||
t.Errorf("width=%d: tile %q is %dpx, tile %q is %dpx — the row must be flush",
|
||||
w, c.label, h, tiles[0].label, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// revealTimeout bounds the helper process. A missing or wedged file manager
|
||||
// must not leave a goroutine parked forever.
|
||||
const revealTimeout = 10 * time.Second
|
||||
|
||||
// RevealInFileManager opens the platform file manager with path selected,
|
||||
// falling back to opening its containing directory.
|
||||
//
|
||||
// Writing a log file and only printing where it went is not much use to someone
|
||||
// who is about to attach it to a bug report, so the export shows it instead of
|
||||
// describing it.
|
||||
//
|
||||
// It blocks; callers should run it off the UI goroutine.
|
||||
func RevealInFileManager(path string, logger *slog.Logger) error {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), revealTimeout)
|
||||
defer cancel()
|
||||
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
// -R reveals rather than opens, so Finder highlights the file.
|
||||
return exec.CommandContext(ctx, "open", "-R", abs).Run()
|
||||
|
||||
case "windows":
|
||||
// explorer wants the comma glued to the flag, and exits non-zero even
|
||||
// when it succeeds, so its status is deliberately ignored.
|
||||
_ = exec.CommandContext(ctx, "explorer", "/select,"+abs).Run()
|
||||
return nil
|
||||
|
||||
default:
|
||||
// The freedesktop interface highlights the file; every major Linux file
|
||||
// manager implements it. Fall back to opening the directory when the
|
||||
// service is absent — dbus-send itself may not even be installed.
|
||||
uri := "file://" + abs
|
||||
dbus := exec.CommandContext(ctx, "dbus-send",
|
||||
"--session", "--dest=org.freedesktop.FileManager1", "--type=method_call",
|
||||
"/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems",
|
||||
"array:string:"+uri, "string:tslink",
|
||||
)
|
||||
if err := dbus.Run(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
logger.Debug("FileManager1.ShowItems unavailable, opening the directory",
|
||||
"err", err)
|
||||
}
|
||||
return exec.CommandContext(ctx, "xdg-open", filepath.Dir(abs)).Run()
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/widget"
|
||||
|
||||
"tslink/core"
|
||||
)
|
||||
|
||||
// The services section answers "what did tslink open on this machine, and which
|
||||
// server is behind it".
|
||||
//
|
||||
// It is built entirely from the parsed config plus the peer snapshot the app
|
||||
// already holds — no multicast, no I/O on the render path. The previous LAN page
|
||||
// listened for the same MOTD broadcasts tslink itself emits, which meant the
|
||||
// list was assembled from packets: the same server appeared once per IP family,
|
||||
// nothing deduplicated the two, and the rows were ordered by last-seen so a 1.5s
|
||||
// broadcast cycle permuted them continuously. Deriving the list from config
|
||||
// instead makes it exact and, because it is sorted on a stable key, still.
|
||||
|
||||
// serviceEntry is one local listener created by a connect rule.
|
||||
type serviceEntry struct {
|
||||
Name string // the rule's MOTD, or its config tag
|
||||
Tag string // the [[connect.<tag>]] key
|
||||
Proto string
|
||||
Addr string // the local address a client points at
|
||||
Port int
|
||||
// Broadcast reports that this service is announced on the LAN, i.e. it
|
||||
// shows up in Minecraft's server list without being typed in.
|
||||
Broadcast bool
|
||||
}
|
||||
|
||||
// serviceServer groups every local listener that targets one remote host.
|
||||
type serviceServer struct {
|
||||
// Host is the dst_addr hostname, already MagicDNS-qualified by the
|
||||
// supervisor's NormalizeConnectRulesDstAddr pass.
|
||||
Host string
|
||||
// Peer is the tailnet node Host resolved to, when the peer monitor managed
|
||||
// to resolve it. Nil for destinations outside the tailnet, which are
|
||||
// legitimate config entries and must still render.
|
||||
Peer *core.PeerInfo
|
||||
Services []serviceEntry
|
||||
}
|
||||
|
||||
// Online reports the peer's reachability, defaulting to true when the
|
||||
// destination is not a tailnet peer and we therefore have nothing to say.
|
||||
func (s serviceServer) Online() bool { return s.Peer == nil || s.Peer.Online }
|
||||
|
||||
// Title is the friendliest name available for the target.
|
||||
func (s serviceServer) Title() string {
|
||||
if s.Peer != nil && s.Peer.DisplayName != "" {
|
||||
return s.Peer.DisplayName
|
||||
}
|
||||
return s.Host
|
||||
}
|
||||
|
||||
// buildServices turns connect rules into the per-server view.
|
||||
//
|
||||
// Grouping is by destination host rather than by config tag: a server reached
|
||||
// over both TCP and UDP is written as two tagged rules pointing at the same
|
||||
// dst_addr, and the user thinks of that as one server with two services.
|
||||
func buildServices(cfg *core.Config, snap core.PeerSnapshot) []serviceServer {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tag -> peer, via the links the monitor already resolved.
|
||||
byTag := make(map[string]*core.PeerInfo)
|
||||
for i := range snap.Peers {
|
||||
pr := &snap.Peers[i]
|
||||
for _, tag := range pr.LinkTags {
|
||||
byTag[tag] = pr
|
||||
}
|
||||
}
|
||||
|
||||
grouped := make(map[string]*serviceServer)
|
||||
for tag, rules := range cfg.Connect {
|
||||
for _, rule := range rules {
|
||||
host := rule.DstAddr
|
||||
if h, _, err := net.SplitHostPort(rule.DstAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
g, ok := grouped[host]
|
||||
if !ok {
|
||||
g = &serviceServer{Host: host, Peer: byTag[tag]}
|
||||
grouped[host] = g
|
||||
} else if g.Peer == nil {
|
||||
g.Peer = byTag[tag]
|
||||
}
|
||||
g.Services = append(g.Services, serviceEntry{
|
||||
Name: rule.LANMotdOr(tag),
|
||||
Tag: tag,
|
||||
Proto: rule.Protocol,
|
||||
Addr: net.JoinHostPort(rule.BindIP(), itoa(rule.LocalPort)),
|
||||
Port: rule.LocalPort,
|
||||
Broadcast: rule.LANEnabled(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]serviceServer, 0, len(grouped))
|
||||
for _, g := range grouped {
|
||||
// Stable within a server: port, then protocol for the tcp/udp pair that
|
||||
// shares one.
|
||||
sort.SliceStable(g.Services, func(i, j int) bool {
|
||||
if g.Services[i].Port != g.Services[j].Port {
|
||||
return g.Services[i].Port < g.Services[j].Port
|
||||
}
|
||||
return g.Services[i].Proto < g.Services[j].Proto
|
||||
})
|
||||
out = append(out, *g)
|
||||
}
|
||||
// Ordered by what is actually on screen, so the list reads alphabetically
|
||||
// rather than by a hostname the user may never see. Host breaks ties and
|
||||
// keeps the order total — map iteration is randomised, so without a full
|
||||
// ordering the whole section would reshuffle every frame.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if ti, tj := out[i].Title(), out[j].Title(); ti != tj {
|
||||
return ti < tj
|
||||
}
|
||||
return out[i].Host < out[j].Host
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// copyBtn lazily allocates a clickable per address.
|
||||
func (p *overviewPage) copyBtn(addr string) *widget.Clickable {
|
||||
if p.svcCopy == nil {
|
||||
p.svcCopy = make(map[string]*widget.Clickable)
|
||||
}
|
||||
b, ok := p.svcCopy[addr]
|
||||
if !ok {
|
||||
b = new(widget.Clickable)
|
||||
p.svcCopy[addr] = b
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (p *overviewPage) servicesCard(a *App, gtx C, servers []serviceServer) D {
|
||||
th := a.th
|
||||
card := th.Card()
|
||||
card.Title = th.T(KSvcTitle)
|
||||
card.Subtitle = th.T(KSvcSubtitle)
|
||||
|
||||
return card.Layout(th, gtx, func(gtx C) D {
|
||||
if len(servers) == 0 {
|
||||
return th.EmptyState(gtx, IconServer, th.T(KSvcEmpty), "")
|
||||
}
|
||||
children := make([]layout.FlexChild, 0, len(servers)*2)
|
||||
for i, srv := range servers {
|
||||
if i > 0 {
|
||||
children = append(children, layout.Rigid(th.Divider))
|
||||
}
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.serverGroup(a, gtx, srv)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}
|
||||
|
||||
// serverGroup renders one target host and the services pointing at it.
|
||||
func (p *overviewPage) serverGroup(a *App, gtx C, srv serviceServer) D {
|
||||
th := a.th
|
||||
level := LevelOK
|
||||
if !srv.Online() {
|
||||
level = LevelFail
|
||||
}
|
||||
|
||||
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(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(srv.Title())).Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
// Only worth showing when it differs from the title,
|
||||
// i.e. when the peer resolved to a nicer name.
|
||||
if srv.Peer == nil || srv.Title() == srv.Host {
|
||||
return D{}
|
||||
}
|
||||
return OneLine(th.MonoLabel(SizeCaption, th.P.TextDim, srv.Host)).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
for _, svc := range srv.Services {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.serviceRow(a, gtx, svc)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}
|
||||
|
||||
// serviceRow is the name-over-address entry: the address is the thing a user
|
||||
// actually needs to type somewhere else, so it gets a monospace line of its own
|
||||
// rather than being folded into the label.
|
||||
func (p *overviewPage) serviceRow(a *App, gtx C, svc serviceEntry) D {
|
||||
th := a.th
|
||||
btn := p.copyBtn(svc.Addr)
|
||||
if btn.Clicked(gtx) {
|
||||
a.copyToClipboard(gtx, svc.Addr, "")
|
||||
}
|
||||
|
||||
return layout.Inset{Top: 4, Bottom: 4, Left: SpaceLG}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return IconLink(gtx, gtx.Dp(14), th.P.TextDim)
|
||||
}),
|
||||
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(svc.Name)).Layout),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if !svc.Broadcast {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D {
|
||||
return th.Chip(gtx, ChipStyle{
|
||||
Text: th.T(KSvcBroadcast),
|
||||
Level: LevelInfo,
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, svc.Addr)).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if svc.Proto == "" {
|
||||
return D{}
|
||||
}
|
||||
return th.Chip(gtx, ChipStyle{Text: strings.ToUpper(svc.Proto)})
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return th.IconButton(gtx, btn, IconCopy, LevelNeutral)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tslink/core"
|
||||
)
|
||||
|
||||
// TestBuildServicesGroupsByHost pins the two properties the old LAN page got
|
||||
// wrong: one entry per server (not per rule, and not per IP family), and an
|
||||
// order that does not depend on map iteration.
|
||||
func TestBuildServicesGroupsByHost(t *testing.T) {
|
||||
cfg := &core.Config{
|
||||
Connect: map[string][]core.ConnectRule{
|
||||
// Same destination host over two protocols: must collapse into one
|
||||
// server carrying two services.
|
||||
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}},
|
||||
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}},
|
||||
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565", LanMotd: "SFCraft"}},
|
||||
// Not a tailnet host; it still has to render.
|
||||
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
|
||||
},
|
||||
}
|
||||
|
||||
got := buildServices(cfg, core.PeerSnapshot{})
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 servers, got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// Sorted by host.
|
||||
wantHosts := []string{"a.mc.example", "mc.lxns.net", "server.l4d2.example"}
|
||||
for i, want := range wantHosts {
|
||||
if got[i].Host != want {
|
||||
t.Errorf("server[%d].Host = %q, want %q", i, got[i].Host, want)
|
||||
}
|
||||
}
|
||||
|
||||
l4d2 := got[2]
|
||||
if len(l4d2.Services) != 2 {
|
||||
t.Fatalf("l4d2 should carry both protocols, got %d", len(l4d2.Services))
|
||||
}
|
||||
if l4d2.Services[0].Proto != "tcp" || l4d2.Services[1].Proto != "udp" {
|
||||
t.Errorf("services not ordered by protocol: %+v", l4d2.Services)
|
||||
}
|
||||
// No peer resolved: must not claim the server is down.
|
||||
if !l4d2.Online() {
|
||||
t.Error("a server with no resolved peer should not render as offline")
|
||||
}
|
||||
if l4d2.Title() != "server.l4d2.example" {
|
||||
t.Errorf("Title() = %q, want the host", l4d2.Title())
|
||||
}
|
||||
|
||||
// LANEnabled defaults to true only for minecraft.
|
||||
mc := got[0]
|
||||
if !mc.Services[0].Broadcast {
|
||||
t.Error("a minecraft rule should be marked as broadcast")
|
||||
}
|
||||
if mc.Services[0].Name != "SFCraft" {
|
||||
t.Errorf("Name = %q, want the lan_motd", mc.Services[0].Name)
|
||||
}
|
||||
if got[1].Services[0].Broadcast {
|
||||
t.Error("a plain udp rule should not be marked as broadcast")
|
||||
}
|
||||
|
||||
// Repeated builds must agree, or the section jitters between frames.
|
||||
for i := 0; i < 20; i++ {
|
||||
again := buildServices(cfg, core.PeerSnapshot{})
|
||||
for j := range again {
|
||||
if again[j].Host != got[j].Host {
|
||||
t.Fatalf("ordering is unstable: %q vs %q", again[j].Host, got[j].Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildServicesUsesPeer checks the enrichment path: a resolved peer supplies
|
||||
// the display name and the online state.
|
||||
func TestBuildServicesUsesPeer(t *testing.T) {
|
||||
cfg := &core.Config{
|
||||
Connect: map[string][]core.ConnectRule{
|
||||
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565"}},
|
||||
},
|
||||
}
|
||||
snap := core.PeerSnapshot{Peers: []core.PeerInfo{{
|
||||
ID: "n1", DisplayName: "homelab", Online: false,
|
||||
Linked: true, LinkTags: []string{"sfcraft"},
|
||||
}}}
|
||||
|
||||
got := buildServices(cfg, snap)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1 server, got %d", len(got))
|
||||
}
|
||||
if got[0].Title() != "homelab" {
|
||||
t.Errorf("Title() = %q, want the peer display name", got[0].Title())
|
||||
}
|
||||
if got[0].Online() {
|
||||
t.Error("an offline peer should make the server render as offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServicesNilConfig(t *testing.T) {
|
||||
if got := buildServices(nil, core.PeerSnapshot{}); got != nil {
|
||||
t.Errorf("want nil for a nil config, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//go:build shots
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/gpu/headless"
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
|
||||
"tslink/core"
|
||||
"tslink/netdiag"
|
||||
)
|
||||
|
||||
// Offscreen renders of the changed UI, for eyeballing what no assertion can
|
||||
// capture — glyph coverage at bold weights, legend wrapping, how full the chart
|
||||
// looks with only a few samples.
|
||||
//
|
||||
// go test ./gui/ -tags shots -run TestShots
|
||||
//
|
||||
// Build-tagged so the normal suite stays GPU-free and font-config independent.
|
||||
|
||||
const shotDir = "/tmp/tslink-shots"
|
||||
|
||||
func shoot(t *testing.T, th *Theme, name string, size image.Point, w func(gtx C) D) {
|
||||
t.Helper()
|
||||
win, err := headless.NewWindow(size.X, size.Y)
|
||||
if err != nil {
|
||||
t.Skipf("no GPU backend: %v", err)
|
||||
}
|
||||
defer win.Release()
|
||||
|
||||
var r input.Router
|
||||
ops := new(op.Ops)
|
||||
// Two frames: the second takes the paths that depend on widget state.
|
||||
for i := 0; i < 2; i++ {
|
||||
ops.Reset()
|
||||
gtx := layout.Context{
|
||||
Ops: ops,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
Now: time.Now(),
|
||||
Source: r.Source(),
|
||||
}
|
||||
paint.Fill(gtx.Ops, th.P.Bg)
|
||||
w(gtx)
|
||||
if err := win.Frame(ops); err != nil {
|
||||
t.Fatalf("frame: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rectangle{Max: size})
|
||||
if err := win.Screenshot(img); err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
f, err := os.Create(shotDir + "/" + name + ".png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote %s/%s.png", shotDir, name)
|
||||
}
|
||||
|
||||
// realTheme builds the theme the way the app does, including the host's CJK
|
||||
// font. Unlike testTheme this deliberately depends on the local font config —
|
||||
// that is the thing under inspection.
|
||||
func realTheme(t *testing.T) *Theme {
|
||||
t.Helper()
|
||||
fonts := LoadFonts()
|
||||
if !fonts.HasCJK {
|
||||
t.Skip("no CJK font on this host")
|
||||
}
|
||||
faces, err := LoadCJKFaces(fonts.CJKPath, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("cjk: %v", err)
|
||||
}
|
||||
fonts.Collection = append(fonts.Collection, faces...)
|
||||
th := NewTheme(fonts, true)
|
||||
th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
|
||||
th.Lang = LangZH
|
||||
return th
|
||||
}
|
||||
|
||||
func shotApp(t *testing.T, th *Theme) *App {
|
||||
a := testApp(t)
|
||||
a.th = th
|
||||
return a
|
||||
}
|
||||
|
||||
func TestShots(t *testing.T) {
|
||||
if err := os.MkdirAll(shotDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
th := realTheme(t)
|
||||
|
||||
// --- 1. CJK at every weight the UI uses -------------------------------
|
||||
// The bug was that only weight 400 had a CJK face, so everything below
|
||||
// rendered as tofu boxes. All five lines must show Chinese glyphs.
|
||||
t.Run("cjk-weights", func(t *testing.T) {
|
||||
weights := []struct {
|
||||
w font.Weight
|
||||
name string
|
||||
}{
|
||||
{font.Normal, "Normal 正文:延迟图谱 已关联 本机服务"},
|
||||
{font.Medium, "Medium 按钮:重试 导出日志 刷新"},
|
||||
{font.SemiBold, "SemiBold 标题:网络诊断 节点延迟"},
|
||||
{font.Bold, "Bold 强调:局域网 转发规则"},
|
||||
}
|
||||
shoot(t, th, "cjk-weights", image.Pt(560, 200), func(gtx C) D {
|
||||
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
|
||||
children := make([]layout.FlexChild, 0, len(weights)*2)
|
||||
for _, w := range weights {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
l := th.Text(SizeSubtitle, th.P.TextPri, w.name)
|
||||
l.Font.Weight = w.w
|
||||
return l.Layout(gtx)
|
||||
}), VGap(SpaceSM))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- 2. Splash, normal and stuck --------------------------------------
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
age time.Duration
|
||||
}{
|
||||
{"splash", 10 * time.Second},
|
||||
{"splash-stuck", 45 * time.Second},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := shotApp(t, th)
|
||||
steps := splashTestSteps()
|
||||
for i := range steps {
|
||||
if steps[i].State == core.StepRunning {
|
||||
steps[i].Started = time.Now().Add(-tc.age)
|
||||
}
|
||||
}
|
||||
st := core.State{
|
||||
Phase: core.PhaseStarting, Steps: steps,
|
||||
StartedAt: time.Now().Add(-tc.age),
|
||||
}
|
||||
shoot(t, th, tc.name, image.Pt(460, 450), func(gtx C) D {
|
||||
return a.splash.Layout(a, gtx, st)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- 3. Chart + legend with 8 series and only 30s of history ----------
|
||||
// Previously this filled ~2.5% of the plot and clipped the legend.
|
||||
t.Run("chart", func(t *testing.T) {
|
||||
a := shotApp(t, th)
|
||||
p := a.peers
|
||||
series := p.buildSeries(th, shotPeers())
|
||||
shoot(t, th, "chart", image.Pt(760, 400), func(gtx C) D {
|
||||
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return p.chartCard(a, gtx, series)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- 3b. Stat tiles: equal height with and without a hint -------------
|
||||
t.Run("stat-tiles", func(t *testing.T) {
|
||||
a := shotApp(t, th)
|
||||
st := readyState(t)
|
||||
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
|
||||
{ID: "n1", DisplayName: "a", Online: true, Linked: true},
|
||||
}}
|
||||
servers := buildServices(st.Config, snap)
|
||||
shoot(t, th, "stat-tiles", image.Pt(1000, 160), func(gtx C) D {
|
||||
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return a.overview.statRow(a, gtx, st, snap, servers)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- 4. Services card grouped per server ------------------------------
|
||||
t.Run("services", func(t *testing.T) {
|
||||
a := shotApp(t, th)
|
||||
cfg := &core.Config{Connect: map[string][]core.ConnectRule{
|
||||
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565", LanMotd: "SFCraft Vanilla | 原版生电 1.21.8"}},
|
||||
"mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}},
|
||||
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
|
||||
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
|
||||
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
|
||||
}}
|
||||
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
|
||||
{ID: "n1", DisplayName: "homelab-mc", Online: true, Linked: true, LinkTags: []string{"sfcraft"}},
|
||||
{ID: "n2", DisplayName: "l4d2-box", Online: false, Linked: true, LinkTags: []string{"l4d2_tcp", "l4d2_udp"}},
|
||||
}}
|
||||
servers := buildServices(cfg, snap)
|
||||
shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {
|
||||
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return a.overview.servicesCard(a, gtx, servers)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestShotsDiag renders the UDP table, which must name servers rather than
|
||||
// print bare resolved addresses.
|
||||
func TestShotsDiag(t *testing.T) {
|
||||
if err := os.MkdirAll(shotDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
th := realTheme(t)
|
||||
a := shotApp(t, th)
|
||||
a.current = pageDiag
|
||||
st := readyState(t)
|
||||
a.diag.report = diagShotReport()
|
||||
shoot(t, th, "diag-udp", image.Pt(1120, 900), func(gtx C) D {
|
||||
return a.diag.Layout(a, gtx, st)
|
||||
})
|
||||
}
|
||||
|
||||
// diagShotReport is a healthy report whose only complaint is an HTTP-only
|
||||
// egress split — the case that must read as a yellow "may affect", not a red
|
||||
// "is affecting".
|
||||
func diagShotReport() *netdiag.Report {
|
||||
rep := &netdiag.Report{
|
||||
StartedAt: time.Now().Add(-18 * time.Second),
|
||||
Duration: 17 * time.Second,
|
||||
UDP: netdiag.UDPReport{
|
||||
Status: netdiag.StatusOK, Summary: "UDP 可用", V4OK: true,
|
||||
CNReachable: 2, CNTotal: 2, IntlReachabl: 1, IntlTotal: 2,
|
||||
Probes: []netdiag.UDPProbe{
|
||||
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米",
|
||||
Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond,
|
||||
Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
|
||||
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米",
|
||||
Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond,
|
||||
Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
|
||||
{Host: "stun.chat.bilibili.com:3478", Target: "203.107.1.33:3478", Name: "哔哩哔哩",
|
||||
Region: netdiag.RegionCN, OK: true, RTT: 21 * time.Millisecond,
|
||||
Mapped: netip.MustParseAddrPort("1.2.3.4:54322")},
|
||||
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google",
|
||||
Region: netdiag.RegionIntl, Err: "i/o timeout"},
|
||||
},
|
||||
},
|
||||
NAT: netdiag.NATReport{
|
||||
Status: netdiag.StatusOK, Type: netdiag.NATFullCone,
|
||||
Mapping: netdiag.BehaviorEndpointIndependent,
|
||||
Filtering: netdiag.BehaviorUnknown,
|
||||
},
|
||||
Overseas: netdiag.OverseasReport{Status: netdiag.StatusOK, Summary: "境外可达"},
|
||||
Egress: netdiag.EgressReport{
|
||||
Observations: []netdiag.EgressObservation{
|
||||
{Method: netdiag.MethodSTUN, Source: "stun.miwifi.com:3478", IP: netip.MustParseAddr("1.2.3.4")},
|
||||
{Method: netdiag.MethodHTTPv4, Source: "https://example/ip", IP: netip.MustParseAddr("5.6.7.8")},
|
||||
},
|
||||
},
|
||||
}
|
||||
eg := &rep.Egress
|
||||
eg.UniqueIPs = []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")}
|
||||
eg.Divergent = true
|
||||
eg.DivergentSTUN = false
|
||||
eg.Status = netdiag.StatusWarn
|
||||
eg.Summary = "出口 IP 不一致:IPv4 有 2 个(1.2.3.4、5.6.7.8),仅 HTTP 探测存在差异"
|
||||
rep.Status = netdiag.StatusWarn
|
||||
rep.Headline = "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接"
|
||||
return rep
|
||||
}
|
||||
|
||||
// shotPeers fabricates eight linked peers with ~30 seconds of history each —
|
||||
// the short-uptime case the chart used to render almost entirely blank, and
|
||||
// enough series to force the legend to wrap.
|
||||
func shotPeers() []core.PeerInfo {
|
||||
now := time.Now()
|
||||
names := []string{
|
||||
"sfcraft-homelab", "mayday", "l4d2-server", "voice-relay",
|
||||
"mcp2-survival", "backup-node", "gateway-cn", "storage-nas",
|
||||
}
|
||||
peers := make([]core.PeerInfo, 0, len(names))
|
||||
for i, n := range names {
|
||||
var samples []core.PeerSample
|
||||
for k := 0; k < 4; k++ {
|
||||
samples = append(samples, core.PeerSample{
|
||||
At: now.Add(time.Duration(-30+k*10) * time.Second),
|
||||
Latency: time.Duration(18+i*9+k*4) * time.Millisecond,
|
||||
OK: true,
|
||||
})
|
||||
}
|
||||
peers = append(peers, core.PeerInfo{
|
||||
ID: n, DisplayName: n, Online: true, Linked: true,
|
||||
LinkTags: []string{n}, Route: core.RouteDirect,
|
||||
LastLatency: samples[len(samples)-1].Latency, LatencyOK: true,
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
return peers
|
||||
}
|
||||
+149
-129
@@ -2,28 +2,52 @@ package gui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"log/slog"
|
||||
"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/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"tslink/core"
|
||||
|
||||
"gioui.org/op/paint"
|
||||
)
|
||||
|
||||
// Window geometry. The splash is sized to just its progress bar and checklist —
|
||||
// it has nothing else to show, and a loading screen floating in a 1120x740
|
||||
// window reads as a broken main window rather than as progress. App.layout
|
||||
// grows the window to the shell dimensions once the service is ready.
|
||||
const (
|
||||
splashWindowW unit.Dp = 460
|
||||
splashWindowH unit.Dp = 450
|
||||
splashMinW unit.Dp = 380
|
||||
splashMinH unit.Dp = 380
|
||||
|
||||
shellWindowW unit.Dp = 1120
|
||||
shellWindowH unit.Dp = 740
|
||||
shellMinW unit.Dp = 880
|
||||
shellMinH unit.Dp = 560
|
||||
)
|
||||
|
||||
// stuckAfter is how long a single boot step may run before the splash offers
|
||||
// the log export. Tailscale's first connection legitimately takes several
|
||||
// seconds, so this has to be long enough not to cry wolf, but short enough that
|
||||
// someone staring at a hung step is told what to do about it.
|
||||
const stuckAfter = 20 * time.Second
|
||||
|
||||
// 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
|
||||
retry widget.Clickable
|
||||
export 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.
|
||||
@@ -56,6 +80,16 @@ func stepTitle(th *Theme, key string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// stalled reports whether a step has been running long enough to look stuck.
|
||||
func stalled(st core.State) bool {
|
||||
for _, step := range st.Steps {
|
||||
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *splashView) Layout(a *App, gtx C, st core.State) D {
|
||||
th := a.th
|
||||
paint.Fill(gtx.Ops, th.P.Bg)
|
||||
@@ -63,40 +97,49 @@ func (s *splashView) Layout(a *App, gtx C, st core.State) D {
|
||||
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
|
||||
if s.export.Clicked(gtx) {
|
||||
s.exportLogs(a)
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// 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(400))
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return layout.Inset{
|
||||
Top: SpaceLG, Bottom: SpaceLG, Left: SpaceMD, Right: SpaceMD,
|
||||
}.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)} }),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// exportLogs writes the current buffer to a file and reports where it went.
|
||||
// This is the splash's replacement for the live log tail: someone looking at a
|
||||
// stuck boot needs the log in a file they can attach, not on screen.
|
||||
func (s *splashView) exportLogs(a *App) {
|
||||
if a.opt.Logs == nil {
|
||||
return
|
||||
}
|
||||
content := a.opt.Logs.ExportText(core.ExportOptions{
|
||||
Header: a.diagnosticHeader(),
|
||||
// Debug and up: a stuck boot is exactly when the quiet records matter.
|
||||
Query: core.LogQuery{MinLevel: slog.LevelDebug},
|
||||
})
|
||||
path, err := saveLogFile(content)
|
||||
if err != nil {
|
||||
a.notify(a.th.T(KError)+": "+err.Error(), LevelFail)
|
||||
return
|
||||
}
|
||||
a.notify(path, LevelOK)
|
||||
a.reveal(path)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -122,49 +165,6 @@ func (s *splashView) panel(a *App, gtx C, st core.State) D {
|
||||
)
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -226,11 +226,19 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if step.State != core.StepDone || step.Elapsed() < 100*time.Millisecond {
|
||||
// A running step shows its timer once it is slow enough to be
|
||||
// worth watching; a finished one shows what it cost.
|
||||
switch {
|
||||
case step.State == core.StepRunning && step.Elapsed() >= time.Second:
|
||||
case step.State == core.StepDone && step.Elapsed() >= 100*time.Millisecond:
|
||||
default:
|
||||
return D{}
|
||||
}
|
||||
return th.MonoLabel(SizeCaption, th.P.TextDim,
|
||||
FormatLatency(step.Elapsed())).Layout(gtx)
|
||||
col := th.P.TextDim
|
||||
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
|
||||
col = th.P.Warn
|
||||
}
|
||||
return th.MonoLabel(SizeCaption, col, FormatLatency(step.Elapsed())).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -238,60 +246,72 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
|
||||
|
||||
func (s *splashView) footer(a *App, gtx C, st core.State) D {
|
||||
th := a.th
|
||||
stuck := stalled(st)
|
||||
|
||||
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 layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(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)
|
||||
}),
|
||||
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)
|
||||
}
|
||||
default:
|
||||
hint, col := th.T(KSplashHint), th.P.TextDim
|
||||
if stuck {
|
||||
hint, col = th.T(KSplashStuckHint), th.P.Warn
|
||||
}
|
||||
l := th.Text(SizeCaption, col, hint)
|
||||
l.Alignment = text.Middle
|
||||
l.MaxLines = 3
|
||||
return l.Layout(gtx)
|
||||
}
|
||||
}),
|
||||
VGap(SpaceMD),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if a.opt.Logs == nil {
|
||||
return D{}
|
||||
}
|
||||
// Promoted once something looks stuck: that is the moment the
|
||||
// log is worth exporting.
|
||||
kind := ButtonGhost
|
||||
if stuck || st.Phase == core.PhaseError {
|
||||
kind = ButtonSubtle
|
||||
}
|
||||
gtx.Constraints.Min.X = 0
|
||||
return th.Button(gtx, &s.export, ButtonStyle{
|
||||
Kind: kind,
|
||||
Text: th.T(KSplashExportLog),
|
||||
Icon: IconSave,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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())
|
||||
}
|
||||
|
||||
+58
-9
@@ -139,21 +139,12 @@ func Circle(gtx C, diameter int, col color.NRGBA) D {
|
||||
// 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} }
|
||||
|
||||
@@ -167,6 +158,64 @@ func HGap(v unit.Dp) layout.FlexChild {
|
||||
return layout.Rigid(layout.Spacer{Width: v}.Layout)
|
||||
}
|
||||
|
||||
// WrapRow lays children out left to right, starting a new line whenever the
|
||||
// next child would not fit. gap is the vertical space between lines; horizontal
|
||||
// spacing is left to the children's own insets.
|
||||
//
|
||||
// Gio's Flex does not wrap — it divides the available space among its children
|
||||
// and lets the overflow clip — and gioui.org/x (which has outlay.FlowWrap) is
|
||||
// not a dependency, so this measures each child with op.Record and packs the
|
||||
// results greedily. Children are recorded once and replayed at their final
|
||||
// offset, so the cost is one layout pass, not two.
|
||||
func WrapRow(gtx C, gap unit.Dp, children []layout.Widget) D {
|
||||
if len(children) == 0 {
|
||||
return D{}
|
||||
}
|
||||
maxW := gtx.Constraints.Max.X
|
||||
|
||||
// Each child is measured against the full width but with no minimum, so a
|
||||
// child wider than the row still gets a line to itself rather than a
|
||||
// negative constraint.
|
||||
cgtx := gtx
|
||||
cgtx.Constraints.Min = image.Point{}
|
||||
|
||||
type placed struct {
|
||||
call op.CallOp
|
||||
dims D
|
||||
x, y int
|
||||
}
|
||||
var (
|
||||
items []placed
|
||||
rowW, rowH int
|
||||
total, lineNo int
|
||||
)
|
||||
vgap := gtx.Dp(gap)
|
||||
|
||||
for _, w := range children {
|
||||
macro := op.Record(gtx.Ops)
|
||||
dims := w(cgtx)
|
||||
call := macro.Stop()
|
||||
|
||||
if rowW > 0 && rowW+dims.Size.X > maxW {
|
||||
// Commit the line and start the next one.
|
||||
total += rowH + vgap
|
||||
rowW, rowH = 0, 0
|
||||
lineNo++
|
||||
}
|
||||
items = append(items, placed{call: call, dims: dims, x: rowW, y: total})
|
||||
rowW += dims.Size.X
|
||||
rowH = max(rowH, dims.Size.Y)
|
||||
}
|
||||
total += rowH
|
||||
|
||||
for _, it := range items {
|
||||
off := op.Offset(image.Pt(it.x, it.y)).Push(gtx.Ops)
|
||||
it.call.Add(gtx.Ops)
|
||||
off.Pop()
|
||||
}
|
||||
return D{Size: image.Pt(maxW, total)}
|
||||
}
|
||||
|
||||
// Divider draws a hairline separator.
|
||||
func (t *Theme) Divider(gtx C) D {
|
||||
h := max(gtx.Dp(1), 1)
|
||||
|
||||
Reference in New Issue
Block a user