add: gui and tsdiag

This commit is contained in:
iceBear67
2026-07-26 09:39:17 +00:00
parent 5dc1759d80
commit 5b3a7e147c
41 changed files with 16451 additions and 8 deletions
+297
View File
@@ -0,0 +1,297 @@
package gui
import (
"image"
"math"
"time"
"gioui.org/f32"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
// splashView is the loading screen. It covers the window until the service is
// up, which is also the window during which the CJK font is parsed and
// tailscale negotiates its first connection — both slow enough that showing a
// bare grey rectangle would read as a hang.
type splashView struct {
retry widget.Clickable
// list keeps the panel reachable on short windows. Without it the retry
// button — the one control on this screen — falls off the bottom edge once
// the checklist and an error message are both showing.
list widget.List
}
func newSplashView() *splashView {
s := &splashView{}
s.list.Axis = layout.Vertical
return s
}
// stepTitles maps supervisor step keys onto localised labels.
func stepTitle(th *Theme, key string) string {
switch key {
case core.StepKeyConfig:
return th.T(KStepConfig)
case core.StepKeyTsnet:
return th.T(KStepTsnet)
case core.StepKeyRules:
return th.T(KStepRules)
case core.StepKeyServices:
return th.T(KStepDiscovery)
case core.StepKeyMonitors:
return th.T(KStepMonitors)
case core.StepKeyReady:
return th.T(KStepReady)
default:
return key
}
}
func (s *splashView) Layout(a *App, gtx C, st core.State) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
if s.retry.Clicked(gtx) && a.opt.Supervisor != nil {
a.opt.Supervisor.Restart()
}
// The docked log sheet sits along the bottom edge, so the panel is centred
// in whatever is left above it. Reserving the space rather than stacking
// the two is the whole point: a screenshot taken mid-load has to show both
// the checklist and the log.
reserve := dockedReserve(gtx)
if maxReserve := gtx.Constraints.Max.Y / 2; reserve > maxReserve {
reserve = maxReserve
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
// A single-element list: centred when it fits, scrollable when the
// window is too short for the checklist plus an error message.
return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(460))
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{Top: SpaceLG, Bottom: SpaceLG}.Layout(gtx, func(gtx C) D {
return s.panel(a, gtx, st)
})
})
})
}),
layout.Rigid(func(gtx C) D { return D{Size: image.Pt(0, reserve)} }),
)
}
func (s *splashView) panel(a *App, gtx C, st core.State) D {
th := a.th
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return s.pulse(a, gtx, st)
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
l := th.Text(SizeDisplay, th.P.TextPri, "tslink")
l.Font.Weight = font.Bold
l.Alignment = text.Middle
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
l := th.Caption(th.T(KAppSubtitle))
l.Alignment = text.Middle
return l.Layout(gtx)
}),
VGap(SpaceLG),
layout.Rigid(func(gtx C) D {
return th.ProgressBar(gtx, st.Progress(), th.P.Accent)
}),
VGap(SpaceLG),
layout.Rigid(func(gtx C) D {
return s.checklist(a, gtx, st)
}),
layout.Rigid(func(gtx C) D {
return s.footer(a, gtx, st)
}),
)
}
// pulse draws concentric rings radiating from a solid core. Three rings offset
// in phase read as continuous motion without a spinning element, which suits a
// "connecting to a network" wait better than a rotating arc.
func (s *splashView) pulse(a *App, gtx C, st core.State) D {
th := a.th
size := gtx.Dp(64)
center := f32.Pt(float32(size)/2, float32(size)/2)
col := th.P.Accent
switch st.Phase {
case core.PhaseError:
col = th.P.Fail
case core.PhaseRetrying:
col = th.P.Warn
}
const period = 2400 * time.Millisecond
base := float32(gtx.Dp(14))
grow := float32(size)/2 - base
if st.Phase != core.PhaseError {
phase := float64(gtx.Now.UnixNano()%int64(period)) / float64(period)
for i := 0; i < 3; i++ {
p := math.Mod(phase+float64(i)/3, 1)
r := base + grow*float32(p)
// Ease the fade so rings vanish before they hit the edge.
alpha := float32(1-p) * 0.55
drawArc(gtx, center, r, float32(gtx.Dp(1.5)), 0, 2*math.Pi, WithAlpha(col, alpha))
}
// A 2.4s cycle does not need 25fps, and this is the one animation that
// can legitimately run for minutes while tailscale negotiates.
animateSlow(gtx)
}
// Solid core.
d := gtx.Dp(22)
off := op.Offset(image.Pt((size-d)/2, (size-d)/2)).Push(gtx.Ops)
Circle(gtx, d, col)
off.Pop()
return D{Size: image.Pt(size, size)}
}
func (s *splashView) checklist(a *App, gtx C, st core.State) D {
th := a.th
children := make([]layout.FlexChild, 0, len(st.Steps))
for _, step := range st.Steps {
children = append(children, layout.Rigid(func(gtx C) D {
return s.stepRow(a, gtx, step)
}))
}
card := th.Card()
card.Pad = SpaceMD
card.Bg = &th.P.BgElevated
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
th := a.th
var (
fg = th.P.TextDim
badge layout.Widget
)
switch step.State {
case core.StepRunning:
fg = th.P.TextPri
badge = func(gtx C) D { return th.Spinner(gtx, gtx.Dp(14), th.P.Accent) }
case core.StepDone:
fg = th.P.TextSec
badge = func(gtx C) D { return IconCheck(gtx, gtx.Dp(14), th.P.OK) }
case core.StepFailed:
fg = th.P.Fail
badge = func(gtx C) D { return IconWarn(gtx, gtx.Dp(14), th.P.Fail) }
case core.StepSkipped:
badge = func(gtx C) D { return Circle(gtx, gtx.Dp(6), th.P.TextDim) }
default:
badge = func(gtx C) D {
// An empty ring reads as "not started" without adding a colour.
drawArc(gtx, f32.Pt(7, 7), 5, 1, 0, 2*math.Pi, WithAlpha(th.P.TextDim, 0.5))
return D{Size: image.Pt(gtx.Dp(14), gtx.Dp(14))}
}
}
return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(18)
return layout.W.Layout(gtx, badge)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(OneLine(th.Text(SizeBody, fg, stepTitle(th, step.Key))).Layout),
layout.Rigid(func(gtx C) D {
if step.Err == "" {
return D{}
}
return OneLine(th.Text(SizeCaption, th.P.Fail, step.Err)).Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx C) D {
if step.State != core.StepDone || step.Elapsed() < 100*time.Millisecond {
return D{}
}
return th.MonoLabel(SizeCaption, th.P.TextDim,
FormatLatency(step.Elapsed())).Layout(gtx)
}),
)
})
}
func (s *splashView) footer(a *App, gtx C, st core.State) D {
th := a.th
return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D {
switch st.Phase {
case core.PhaseError:
// Error text and the retry button sit side by side: stacking them
// pushes the only control on this screen below the fold on a short
// window.
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
l := th.Text(SizeCaption, th.P.Fail, st.Err)
l.MaxLines = 4
return l.Layout(gtx)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = 0
return th.Button(gtx, &s.retry, ButtonStyle{
Kind: ButtonPrimary,
Text: th.T(KRetry),
Icon: IconRefresh,
})
}),
)
case core.PhaseRetrying:
msg := th.T(KSplashRetry)
if st.Err != "" {
msg = st.Err
}
l := th.Text(SizeCaption, th.P.Warn, msg)
l.Alignment = text.Middle
l.MaxLines = 3
return l.Layout(gtx)
default:
l := th.Caption(th.T(KSplashHint))
l.Alignment = text.Middle
return l.Layout(gtx)
}
})
}
// ---------------------------------------------------------------------------
// Shared: a translucent panel backdrop
// ---------------------------------------------------------------------------
// glassPanel fills the current bounds with a translucent surface plus border.
// It is what makes the log overlay readable over whatever is behind it while
// still showing that something is behind it.
func glassPanel(t *Theme, gtx C, size image.Point, radius float32) {
r := int(radius)
bg := t.P.BgElevated
bg.A = 0xE0
paint.FillShape(gtx.Ops, bg, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops))
spec := clip.UniformRRect(image.Rectangle{Max: size}, r).Path(gtx.Ops)
paint.FillShape(gtx.Ops, WithAlpha(t.P.BorderHi, 0.8),
clip.Stroke{Path: spec, Width: 1}.Op())
}