742 lines
20 KiB
Go
742 lines
20 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 *app.Window
|
|
|
|
nav []navEntry
|
|
current pageID
|
|
|
|
overview *overviewPage
|
|
peers *peersPage
|
|
diag *diagPage
|
|
logs *logsPage
|
|
settings *settingsPage
|
|
|
|
splash *splashView
|
|
|
|
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
|
|
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 opens the window and drives the event loop. It returns when the window
|
|
// closes.
|
|
func (a *App) Run(ctx context.Context) error {
|
|
w := new(app.Window)
|
|
// Opens at splash size; grown to the shell dimensions below once the
|
|
// service is ready.
|
|
w.Option(
|
|
app.Title("tslink"),
|
|
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 {
|
|
switch e := w.Event().(type) {
|
|
case app.DestroyEvent:
|
|
return e.Err
|
|
case app.FrameEvent:
|
|
gtx := app.NewContext(&ops, e)
|
|
a.applyFontUpgrade()
|
|
a.layout(gtx)
|
|
e.Frame(gtx.Ops)
|
|
// 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.
|
|
func (a *App) upgradeFonts() {
|
|
if !a.fonts.HasCJK || a.fonts.CJKPath == "" {
|
|
return
|
|
}
|
|
faces, err := LoadCJKFaces(a.fonts.CJKPath, a.logger)
|
|
if err != nil {
|
|
a.logger.Warn("failed to load cjk font, relying on system fallback",
|
|
"path", a.fonts.CJKPath, "err", err)
|
|
return
|
|
}
|
|
if len(faces) == 0 {
|
|
return
|
|
}
|
|
select {
|
|
case a.fontUpgrade <- faces:
|
|
if a.win != nil {
|
|
a.win.Invalidate()
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (a *App) applyFontUpgrade() {
|
|
select {
|
|
case faces := <-a.fontUpgrade:
|
|
merged := append(append([]font.FontFace(nil), a.fonts.Collection...), faces...)
|
|
a.fonts.Collection = merged
|
|
a.th.Shaper = text.NewShaper(text.WithCollection(merged))
|
|
a.logger.Debug("shaper upgraded with cjk faces", "faces", len(faces))
|
|
default:
|
|
}
|
|
}
|
|
|
|
// watch coalesces change notifications from every data source into window
|
|
// invalidations, capped so a burst of log lines cannot drive the render loop.
|
|
func (a *App) watch(ctx context.Context, w *app.Window) {
|
|
var chans []<-chan struct{}
|
|
var cancels []func()
|
|
defer func() {
|
|
for _, c := range cancels {
|
|
c()
|
|
}
|
|
}()
|
|
|
|
if a.opt.Supervisor != nil {
|
|
ch, cancel := a.opt.Supervisor.Subscribe()
|
|
chans = append(chans, ch)
|
|
cancels = append(cancels, cancel)
|
|
}
|
|
if a.opt.Logs != nil {
|
|
ch, cancel := a.opt.Logs.Subscribe()
|
|
chans = append(chans, ch)
|
|
cancels = append(cancels, cancel)
|
|
}
|
|
|
|
// A ticker keeps relative timestamps ("3m ago") and the live latency
|
|
// column honest even when nothing else changed.
|
|
tick := time.NewTicker(time.Second)
|
|
defer tick.Stop()
|
|
|
|
dirty := false
|
|
throttle := time.NewTicker(70 * time.Millisecond)
|
|
defer throttle.Stop()
|
|
|
|
agg := make(chan struct{}, 1)
|
|
for _, ch := range chans {
|
|
go func(ch <-chan struct{}) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case _, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case agg <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}(ch)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-agg:
|
|
dirty = true
|
|
case <-tick.C:
|
|
if a.needsTick.Load() {
|
|
dirty = true
|
|
}
|
|
case <-throttle.C:
|
|
if dirty {
|
|
dirty = false
|
|
w.Invalidate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// state returns the current supervisor snapshot, or a zero value.
|
|
func (a *App) state() core.State {
|
|
if a.opt.Supervisor == nil {
|
|
return core.State{}
|
|
}
|
|
return a.opt.Supervisor.Snapshot()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Clipboard + toast
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// copyToClipboard puts s on the system clipboard and shows a confirmation.
|
|
func (a *App) copyToClipboard(gtx C, s string, msg string) {
|
|
gtx.Execute(clipboard.WriteCmd{
|
|
Type: "application/text",
|
|
Data: io.NopCloser(strings.NewReader(s)),
|
|
})
|
|
if msg == "" {
|
|
msg = a.th.T(KCopied)
|
|
}
|
|
a.notify(msg, LevelOK)
|
|
}
|
|
|
|
// 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)
|
|
if a.win != nil {
|
|
a.win.Invalidate()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) layout(gtx C) D {
|
|
th := a.th
|
|
paint.Fill(gtx.Ops, th.P.Bg)
|
|
|
|
st := a.state()
|
|
|
|
// A terminal error screen has nothing that ages; everything else does
|
|
// (uptime, "last seen", a running step's timer).
|
|
a.needsTick.Store(st.Phase != core.PhaseError && st.Phase != core.PhaseStopped)
|
|
|
|
// Handle nav clicks before drawing so the click lands on this frame.
|
|
for i := range a.nav {
|
|
if a.nav[i].click.Clicked(gtx) {
|
|
a.current = a.nav[i].id
|
|
}
|
|
}
|
|
if a.themeBtn.Clicked(gtx) {
|
|
th.SetDark(!th.Dark)
|
|
}
|
|
|
|
return layout.Stack{}.Layout(gtx,
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min = gtx.Constraints.Max
|
|
if !st.Ready() {
|
|
// The splash owns the whole window until the service is up.
|
|
return a.splash.Layout(a, gtx, st)
|
|
}
|
|
return a.shell(gtx, st)
|
|
}),
|
|
layout.Stacked(func(gtx C) D {
|
|
gtx.Constraints.Min = gtx.Constraints.Max
|
|
return a.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()
|
|
}
|