768 lines
21 KiB
Go
768 lines
21 KiB
Go
package gui
|
|
|
|
import (
|
|
"context"
|
|
"image"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"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/widget"
|
|
|
|
"tslink/core"
|
|
)
|
|
|
|
// Options configures the GUI.
|
|
type Options struct {
|
|
Version string
|
|
ConfigPath string
|
|
ConfigURL string
|
|
Supervisor *core.Supervisor
|
|
Logs *core.LogBuffer
|
|
Logger *slog.Logger
|
|
// IPInfoToken is passed through to the diagnostics runner.
|
|
IPInfoToken string
|
|
// StartDark selects the initial theme.
|
|
StartDark bool
|
|
}
|
|
|
|
// pageID identifies a top-level view.
|
|
type pageID int
|
|
|
|
const (
|
|
pageOverview pageID = iota
|
|
pagePeers
|
|
pageDiag
|
|
pageLogs
|
|
pageSettings
|
|
)
|
|
|
|
type navEntry struct {
|
|
id pageID
|
|
label Key
|
|
icon IconFunc
|
|
click widget.Clickable
|
|
}
|
|
|
|
// App is the whole GUI. It owns the window event loop and holds every page's
|
|
// state.
|
|
type App struct {
|
|
opt Options
|
|
logger *slog.Logger
|
|
th *Theme
|
|
fonts *FontSet
|
|
|
|
// win is the window currently on screen. It is replaced when the splash
|
|
// hands off to the shell, so background goroutines load it through
|
|
// [App.invalidate] rather than capturing a single window.
|
|
win atomic.Pointer[app.Window]
|
|
|
|
nav []navEntry
|
|
current pageID
|
|
|
|
overview *overviewPage
|
|
peers *peersPage
|
|
diag *diagPage
|
|
logs *logsPage
|
|
settings *settingsPage
|
|
|
|
splash *splashView
|
|
|
|
themeBtn widget.Clickable
|
|
|
|
toastMsg string
|
|
toastLevel StatusLevel
|
|
toastUntil time.Time
|
|
|
|
// fontUpgrade carries CJK faces parsed off the UI goroutine.
|
|
fontUpgrade chan []font.FontFace
|
|
|
|
// needsTick is set during layout when the current frame shows something
|
|
// that changes with wall-clock time — relative timestamps, uptime, a
|
|
// running step's elapsed counter. When it is false the periodic refresh is
|
|
// skipped and the window stops repainting altogether.
|
|
//
|
|
// This is not micro-optimisation: a full repaint costs tens of
|
|
// milliseconds under software rendering (Gio stencils every rounded
|
|
// rectangle and icon as a path), so a once-a-second refresh of a screen
|
|
// with nothing time-dependent on it is pure waste.
|
|
needsTick atomic.Bool
|
|
}
|
|
|
|
// New builds the application.
|
|
func New(opt Options) *App {
|
|
logger := opt.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
fonts := LoadFonts()
|
|
th := NewTheme(fonts, opt.StartDark)
|
|
|
|
a := &App{
|
|
opt: opt,
|
|
logger: logger.With("from", "gui"),
|
|
th: th,
|
|
fonts: fonts,
|
|
current: pageOverview,
|
|
fontUpgrade: make(chan []font.FontFace, 1),
|
|
}
|
|
a.nav = []navEntry{
|
|
{id: pageOverview, label: KNavOverview, icon: IconGrid},
|
|
{id: pagePeers, label: KNavPeers, icon: IconNodes},
|
|
{id: pageDiag, label: KNavDiag, icon: IconPulse},
|
|
{id: pageLogs, label: KNavLogs, icon: IconList},
|
|
{id: pageSettings, label: KNavSettings, icon: IconSliders},
|
|
}
|
|
a.overview = newOverviewPage()
|
|
a.peers = newPeersPage()
|
|
a.diag = newDiagPage(a)
|
|
a.logs = newLogsPage(a)
|
|
a.settings = newSettingsPage(a)
|
|
a.splash = newSplashView()
|
|
return a
|
|
}
|
|
|
|
// Run shows the GUI and returns when it closes.
|
|
//
|
|
// It opens two windows in sequence: a compact splash sized to its progress
|
|
// checklist during boot, then a full-size shell once the service is ready.
|
|
// Each window is created at its final size. Growing a window at runtime — which
|
|
// is what an in-place splash-to-shell transition would need — is unreliable
|
|
// across compositors (Wayland in particular refuses client-driven resizes on
|
|
// some of them), so opening a correctly sized window is the dependable path.
|
|
func (a *App) Run(ctx context.Context) error {
|
|
go a.watch(ctx)
|
|
go a.upgradeFonts()
|
|
|
|
// The splash runs until the service is ready, then closes itself and asks
|
|
// the caller to open the shell. Any other exit — the user closing the
|
|
// window, or ctx being cancelled — quits.
|
|
proceed, err := a.runWindow(ctx, false)
|
|
if err != nil || !proceed || ctx.Err() != nil {
|
|
return err
|
|
}
|
|
_, err = a.runWindow(ctx, true)
|
|
return err
|
|
}
|
|
|
|
// runWindow creates one window and drives its event loop: the compact splash
|
|
// (shell=false) or the full-size shell (shell=true).
|
|
//
|
|
// It reports proceed=true only for the splash's ready handoff — the service
|
|
// came up, so the splash closed itself and the caller should open the shell.
|
|
// A window closed by the user or by ctx cancellation returns proceed=false,
|
|
// which quits the app.
|
|
func (a *App) runWindow(ctx context.Context, shell bool) (proceed bool, err error) {
|
|
w := new(app.Window)
|
|
if shell {
|
|
w.Option(
|
|
app.Title("tslink"),
|
|
app.Size(shellWindowW, shellWindowH),
|
|
app.MinSize(shellMinW, shellMinH),
|
|
)
|
|
} else {
|
|
w.Option(
|
|
app.Title("tslink"),
|
|
app.Size(splashWindowW, splashWindowH),
|
|
app.MinSize(splashMinW, splashMinH),
|
|
)
|
|
}
|
|
a.win.Store(w)
|
|
|
|
// Ctrl+C at the terminal cancels ctx. Without this the supervisor tears
|
|
// down but the window survives — the GUI is the process, so cancelling it
|
|
// has to close the window too. Scoped to this window and stopped when the
|
|
// loop returns, so it never reaches across the handoff to the next one.
|
|
stop := make(chan struct{})
|
|
defer close(stop)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
w.Perform(system.ActionClose)
|
|
case <-stop:
|
|
}
|
|
}()
|
|
|
|
// handoff records that we closed the splash because the service came up, so
|
|
// the resulting DestroyEvent means "open the shell" rather than "quit".
|
|
handoff := false
|
|
var ops op.Ops
|
|
for {
|
|
switch e := w.Event().(type) {
|
|
case app.DestroyEvent:
|
|
return handoff, e.Err
|
|
case app.FrameEvent:
|
|
gtx := app.NewContext(&ops, e)
|
|
a.applyFontUpgrade()
|
|
// The splash window always draws the splash, even on the frame
|
|
// where the service first reports ready: otherwise the shell would
|
|
// flash cramped in the compact window for one frame before handoff.
|
|
a.layout(gtx, !shell)
|
|
e.Frame(gtx.Ops)
|
|
if !shell && a.state().Ready() {
|
|
handoff = true
|
|
w.Perform(system.ActionClose)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// invalidate schedules a repaint of whichever window is currently shown. It is
|
|
// a no-op before the first window exists and is safe from any goroutine.
|
|
func (a *App) invalidate() {
|
|
if w := a.win.Load(); w != nil {
|
|
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.
|
|
func (a *App) upgradeFonts() {
|
|
if !a.fonts.HasCJK || a.fonts.CJKPath == "" {
|
|
return
|
|
}
|
|
faces, err := LoadCJKFaces(a.fonts.CJKPath, a.logger)
|
|
if err != nil {
|
|
a.logger.Warn("failed to load cjk font, relying on system fallback",
|
|
"path", a.fonts.CJKPath, "err", err)
|
|
return
|
|
}
|
|
if len(faces) == 0 {
|
|
return
|
|
}
|
|
select {
|
|
case a.fontUpgrade <- faces:
|
|
a.invalidate()
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (a *App) applyFontUpgrade() {
|
|
select {
|
|
case faces := <-a.fontUpgrade:
|
|
merged := append(append([]font.FontFace(nil), a.fonts.Collection...), faces...)
|
|
a.fonts.Collection = merged
|
|
a.th.Shaper = text.NewShaper(text.WithCollection(merged))
|
|
a.logger.Debug("shaper upgraded with cjk faces", "faces", len(faces))
|
|
default:
|
|
}
|
|
}
|
|
|
|
// watch coalesces change notifications from every data source into window
|
|
// invalidations, capped so a burst of log lines cannot drive the render loop.
|
|
func (a *App) watch(ctx context.Context) {
|
|
var chans []<-chan struct{}
|
|
var cancels []func()
|
|
defer func() {
|
|
for _, c := range cancels {
|
|
c()
|
|
}
|
|
}()
|
|
|
|
if a.opt.Supervisor != nil {
|
|
ch, cancel := a.opt.Supervisor.Subscribe()
|
|
chans = append(chans, ch)
|
|
cancels = append(cancels, cancel)
|
|
}
|
|
if a.opt.Logs != nil {
|
|
ch, cancel := a.opt.Logs.Subscribe()
|
|
chans = append(chans, ch)
|
|
cancels = append(cancels, cancel)
|
|
}
|
|
|
|
// A ticker keeps relative timestamps ("3m ago") and the live latency
|
|
// column honest even when nothing else changed.
|
|
tick := time.NewTicker(time.Second)
|
|
defer tick.Stop()
|
|
|
|
dirty := false
|
|
throttle := time.NewTicker(70 * time.Millisecond)
|
|
defer throttle.Stop()
|
|
|
|
agg := make(chan struct{}, 1)
|
|
for _, ch := range chans {
|
|
go func(ch <-chan struct{}) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case _, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case agg <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}(ch)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-agg:
|
|
dirty = true
|
|
case <-tick.C:
|
|
if a.needsTick.Load() {
|
|
dirty = true
|
|
}
|
|
case <-throttle.C:
|
|
if dirty {
|
|
dirty = false
|
|
a.invalidate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// state returns the current supervisor snapshot, or a zero value.
|
|
func (a *App) state() core.State {
|
|
if a.opt.Supervisor == nil {
|
|
return core.State{}
|
|
}
|
|
return a.opt.Supervisor.Snapshot()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Clipboard + toast
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// copyToClipboard puts s on the system clipboard and shows a confirmation.
|
|
func (a *App) copyToClipboard(gtx C, s string, msg string) {
|
|
gtx.Execute(clipboard.WriteCmd{
|
|
Type: "application/text",
|
|
Data: io.NopCloser(strings.NewReader(s)),
|
|
})
|
|
if msg == "" {
|
|
msg = a.th.T(KCopied)
|
|
}
|
|
a.notify(msg, LevelOK)
|
|
}
|
|
|
|
// 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
|
|
a.toastLevel = level
|
|
a.toastUntil = time.Now().Add(3200 * time.Millisecond)
|
|
a.invalidate()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// layout draws one frame. forceSplash keeps the splash on screen even once the
|
|
// service is ready, which the compact splash window uses so the shell never
|
|
// flashes cramped in it before the handoff to the full-size window.
|
|
func (a *App) layout(gtx C, forceSplash bool) D {
|
|
th := a.th
|
|
paint.Fill(gtx.Ops, th.P.Bg)
|
|
|
|
st := a.state()
|
|
|
|
// A terminal error screen has nothing that ages; everything else does
|
|
// (uptime, "last seen", a running step's timer).
|
|
a.needsTick.Store(st.Phase != core.PhaseError && st.Phase != core.PhaseStopped)
|
|
|
|
// Handle nav clicks before drawing so the click lands on this frame.
|
|
for i := range a.nav {
|
|
if a.nav[i].click.Clicked(gtx) {
|
|
a.current = a.nav[i].id
|
|
}
|
|
}
|
|
if a.themeBtn.Clicked(gtx) {
|
|
th.SetDark(!th.Dark)
|
|
}
|
|
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min = gtx.Constraints.Max
|
|
if forceSplash || !st.Ready() {
|
|
// The splash owns the whole window until the service is up.
|
|
return a.splash.Layout(a, gtx, st)
|
|
}
|
|
return a.shell(gtx, st)
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min = gtx.Constraints.Max
|
|
return a.layoutToast(gtx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
// shell draws the sidebar plus the active page.
|
|
func (a *App) shell(gtx C, st core.State) D {
|
|
compact := gtx.Constraints.Max.X < gtx.Dp(1000)
|
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return a.sidebar(gtx, compact)
|
|
}),
|
|
layout.Flexed(1, func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D { return a.header(gtx, st) }),
|
|
layout.Flexed(1, func(gtx C) D {
|
|
return layout.Inset{
|
|
Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceLG,
|
|
}.Layout(gtx, func(gtx C) D {
|
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
return a.page(gtx, st)
|
|
})
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (a *App) page(gtx C, st core.State) D {
|
|
switch a.current {
|
|
case pagePeers:
|
|
return a.peers.Layout(a, gtx, st)
|
|
case pageDiag:
|
|
return a.diag.Layout(a, gtx, st)
|
|
case pageLogs:
|
|
return a.logs.Layout(a, gtx, st)
|
|
case pageSettings:
|
|
return a.settings.Layout(a, gtx, st)
|
|
default:
|
|
return a.overview.Layout(a, gtx, st)
|
|
}
|
|
}
|
|
|
|
func (a *App) sidebar(gtx C, compact bool) D {
|
|
th := a.th
|
|
w := gtx.Dp(212)
|
|
if compact {
|
|
w = gtx.Dp(64)
|
|
}
|
|
gtx.Constraints.Min.X = w
|
|
gtx.Constraints.Max.X = w
|
|
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
size := image.Pt(w, gtx.Constraints.Max.Y)
|
|
paint.FillShape(gtx.Ops, th.P.BgElevated, clip.Rect{Max: size}.Op())
|
|
// Hairline separating rail from content.
|
|
paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{
|
|
Min: image.Pt(size.X-1, 0), Max: size,
|
|
}.Op())
|
|
return D{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min.X = w
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D { return a.brand(gtx, compact) }),
|
|
layout.Rigid(func(gtx C) D {
|
|
children := make([]layout.FlexChild, 0, len(a.nav))
|
|
for i := range a.nav {
|
|
children = append(children, layout.Rigid(func(gtx C) D {
|
|
return a.navItem(gtx, &a.nav[i], compact)
|
|
}))
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (a *App) brand(gtx C, compact bool) D {
|
|
th := a.th
|
|
return layout.Inset{
|
|
Top: SpaceXL, Bottom: SpaceLG, Left: SpaceLG, Right: SpaceLG,
|
|
}.Layout(gtx, func(gtx C) D {
|
|
if compact {
|
|
return layout.Center.Layout(gtx, func(gtx C) D {
|
|
return IconBroadcast(gtx, gtx.Dp(22), th.P.Accent)
|
|
})
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return IconBroadcast(gtx, gtx.Dp(20), th.P.Accent)
|
|
}),
|
|
HGap(SpaceSM),
|
|
layout.Rigid(func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
l := th.Text(SizeSubtitle, th.P.TextPri, "tslink")
|
|
l.Font.Weight = font.Bold
|
|
return l.Layout(gtx)
|
|
}),
|
|
layout.Rigid(OneLine(th.Caption(th.T(KAppSubtitle))).Layout),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (a *App) navItem(gtx C, n *navEntry, compact bool) D {
|
|
th := a.th
|
|
selected := a.current == n.id
|
|
fg := th.P.TextSec
|
|
if selected {
|
|
fg = th.P.TextPri
|
|
} else if n.click.Hovered() {
|
|
fg = th.P.TextPri
|
|
}
|
|
|
|
return n.click.Layout(gtx, func(gtx C) D {
|
|
return layout.Inset{Left: SpaceSM, Right: SpaceSM, Top: 2, Bottom: 2}.Layout(gtx, func(gtx C) D {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
size := gtx.Constraints.Min
|
|
switch {
|
|
case selected:
|
|
FillRRect(gtx, size, RadiusSM, WithAlpha(th.P.Accent, 0.16))
|
|
paint.FillShape(gtx.Ops, th.P.Accent, clip.UniformRRect(
|
|
image.Rect(0, size.Y/2-gtx.Dp(8), gtx.Dp(3), size.Y/2+gtx.Dp(8)),
|
|
gtx.Dp(2)).Op(gtx.Ops))
|
|
case n.click.Hovered():
|
|
FillRRect(gtx, size, RadiusSM, th.P.SurfaceHi)
|
|
}
|
|
return D{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
pad := layout.Inset{Top: 9, Bottom: 9, Left: SpaceMD, Right: SpaceMD}
|
|
if compact {
|
|
pad = layout.Inset{Top: 10, Bottom: 10}
|
|
}
|
|
return pad.Layout(gtx, func(gtx C) D {
|
|
if compact {
|
|
return layout.Center.Layout(gtx, func(gtx C) D {
|
|
return n.icon(gtx, gtx.Dp(19), fg)
|
|
})
|
|
}
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return n.icon(gtx, gtx.Dp(17), fg)
|
|
}),
|
|
HGap(SpaceMD),
|
|
layout.Rigid(func(gtx C) D {
|
|
l := th.Text(SizeBody, fg, th.T(n.label))
|
|
if selected {
|
|
l.Font.Weight = font.Medium
|
|
}
|
|
return l.Layout(gtx)
|
|
}),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (a *App) header(gtx C, st core.State) D {
|
|
th := a.th
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
size := gtx.Constraints.Min
|
|
paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{
|
|
Min: image.Pt(0, size.Y-1), Max: size,
|
|
}.Op())
|
|
return D{Size: size}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
|
return layout.Inset{
|
|
Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceMD,
|
|
}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx C) D {
|
|
return th.Title(a.pageTitle()).Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx C) D { return a.statusPill(gtx, st) }),
|
|
HGap(SpaceSM),
|
|
layout.Rigid(func(gtx C) D {
|
|
return th.IconButton(gtx, &a.themeBtn, IconGlobe, LevelNeutral)
|
|
}),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (a *App) pageTitle() string {
|
|
th := a.th
|
|
for _, n := range a.nav {
|
|
if n.id == a.current {
|
|
return th.T(n.label)
|
|
}
|
|
}
|
|
return "tslink"
|
|
}
|
|
|
|
func (a *App) statusPill(gtx C, st core.State) D {
|
|
th := a.th
|
|
var (
|
|
label string
|
|
level StatusLevel
|
|
pulse bool
|
|
)
|
|
switch st.Phase {
|
|
case core.PhaseReady:
|
|
// Steady state: no animation. See [Theme.StatusDot].
|
|
label, level = th.T(KStateRunning), LevelOK
|
|
case core.PhaseStarting:
|
|
label, level = th.T(KStateConnecting), LevelInfo
|
|
pulse = true
|
|
case core.PhaseRetrying:
|
|
label, level = th.T(KStateRetrying), LevelWarn
|
|
pulse = true
|
|
case core.PhaseError:
|
|
label, level = th.T(KStateError), LevelFail
|
|
case core.PhaseStopped:
|
|
label, level = th.T(KStateStopped), LevelNeutral
|
|
default:
|
|
label, level = th.T(KStateStarting), LevelNeutral
|
|
}
|
|
|
|
fg := th.StatusColor(level)
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
FillRRect(gtx, gtx.Constraints.Min, RadiusPill, WithAlpha(fg, 0.13))
|
|
return D{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
return layout.Inset{Top: 5, Bottom: 5, Left: SpaceMD, Right: SpaceMD}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return th.StatusDot(gtx, level, pulse)
|
|
}),
|
|
HGap(SpaceSM),
|
|
layout.Rigid(th.Text(SizeCaption, fg, label).Layout),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (a *App) layoutToast(gtx C) D {
|
|
if a.toastMsg == "" || time.Now().After(a.toastUntil) {
|
|
return D{}
|
|
}
|
|
th := a.th
|
|
// Keep repainting until the toast expires.
|
|
gtx.Execute(op.InvalidateCmd{At: a.toastUntil})
|
|
|
|
return layout.S.Layout(gtx, func(gtx C) D {
|
|
return layout.Inset{Bottom: Space2XL}.Layout(gtx, func(gtx C) D {
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Expanded(func(gtx C) D {
|
|
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.SurfaceHi)
|
|
StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border)
|
|
return D{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
return layout.Inset{
|
|
Top: SpaceSM, Bottom: SpaceSM, Left: SpaceLG, Right: SpaceLG,
|
|
}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return th.StatusDot(gtx, a.toastLevel, false)
|
|
}),
|
|
HGap(SpaceSM),
|
|
layout.Rigid(th.Text(SizeBody, th.P.TextPri, a.toastMsg).Layout),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section heading used by pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// sectionTitle renders a page-level heading with an optional trailing widget.
|
|
func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget) D {
|
|
th := a.th
|
|
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Flexed(1, func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
l := th.Text(SizeSubtitle, th.P.TextPri, title)
|
|
l.Font.Weight = font.SemiBold
|
|
return l.Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if subtitle == "" {
|
|
return D{}
|
|
}
|
|
return th.Caption(subtitle).Layout(gtx)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if trailing == nil {
|
|
return D{}
|
|
}
|
|
return trailing(gtx)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
}
|