Files
tslink/gui/page_lan.go
T
2026-07-26 09:39:17 +00:00

191 lines
4.7 KiB
Go

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)
}),
)
})
}