mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-05 03:52:07 -04:00
* feat(tailscale): add Tailscale control center widget Full-stack Tailscale integration for DMS control center: Backend (Go): - Event-driven manager via WatchIPNBus (no polling) - Reconnects with exponential backoff when tailscaled unavailable - Typed conversion from ipnstate.Status to QML-friendly IPC types - Testable via tailscaleClient interface with mock watcher - Manager cleanup in cleanupManagers() - 19 unit tests Frontend (QML): - TailscaleService with WebSocket subscription - TailscaleWidget with peer list, filter chips, search - Copy-to-clipboard for IPs and DNS names - Daemon lifecycle handling (offline/stopped states) Dependencies: - Add tailscale.com v1.96.1 (official local API client) - Bump Go to 1.26.1 (required by tailscale.com) * cleanups --------- Co-authored-by: bbedward <bbedward@gmail.com>
136 lines
2.9 KiB
Go
136 lines
2.9 KiB
Go
package tailscale
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
|
|
func convertStatus(status *ipnstate.Status) *TailscaleState {
|
|
connected := status.BackendState == "Running"
|
|
|
|
state := &TailscaleState{
|
|
Connected: connected,
|
|
BackendState: status.BackendState,
|
|
Version: status.Version,
|
|
}
|
|
|
|
if status.CurrentTailnet != nil {
|
|
state.TailnetName = status.CurrentTailnet.Name
|
|
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
|
|
}
|
|
|
|
if !connected {
|
|
return state
|
|
}
|
|
|
|
users := status.User
|
|
|
|
if status.Self != nil {
|
|
state.Self = convertPeerStatus(status.Self, users)
|
|
}
|
|
|
|
if len(status.Peer) > 0 {
|
|
peers := make([]Peer, 0, len(status.Peer))
|
|
for _, ps := range status.Peer {
|
|
peers = append(peers, convertPeerStatus(ps, users))
|
|
}
|
|
sort.Slice(peers, func(i, j int) bool {
|
|
if peers[i].Online != peers[j].Online {
|
|
return peers[i].Online
|
|
}
|
|
return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname)
|
|
})
|
|
state.Peers = peers
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
|
|
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
|
|
dnsName := strings.TrimSuffix(ps.DNSName, ".")
|
|
|
|
// DNSName first label is unique per node; OS HostName is not.
|
|
hostname := ps.HostName
|
|
if dnsName != "" {
|
|
parts := strings.SplitN(dnsName, ".", 2)
|
|
if len(parts) > 0 && parts[0] != "" {
|
|
hostname = parts[0]
|
|
}
|
|
}
|
|
|
|
peer := Peer{
|
|
ID: string(ps.ID),
|
|
Hostname: hostname,
|
|
DNSName: dnsName,
|
|
OS: ps.OS,
|
|
Online: ps.Online,
|
|
Active: ps.Active,
|
|
ExitNode: ps.ExitNode,
|
|
Relay: ps.Relay,
|
|
RxBytes: ps.RxBytes,
|
|
TxBytes: ps.TxBytes,
|
|
}
|
|
|
|
for _, ip := range ps.TailscaleIPs {
|
|
if ip.Is4() {
|
|
if peer.TailscaleIP == "" {
|
|
peer.TailscaleIP = ip.String()
|
|
}
|
|
} else {
|
|
if peer.TailscaleIPv6 == "" {
|
|
peer.TailscaleIPv6 = ip.String()
|
|
}
|
|
}
|
|
}
|
|
|
|
if ps.Tags != nil {
|
|
peer.Tags = ps.Tags.AsSlice()
|
|
}
|
|
|
|
if ps.UserID > 0 {
|
|
if user, ok := users[ps.UserID]; ok {
|
|
peer.Owner = user.LoginName
|
|
}
|
|
}
|
|
|
|
if !ps.LastSeen.IsZero() {
|
|
peer.LastSeen = formatRelativeTime(ps.LastSeen)
|
|
}
|
|
|
|
return peer
|
|
}
|
|
|
|
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
|
|
func formatRelativeTime(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
m := int(d.Minutes())
|
|
if m == 1 {
|
|
return "1 minute ago"
|
|
}
|
|
return fmt.Sprintf("%d minutes ago", m)
|
|
case d < 24*time.Hour:
|
|
h := int(d.Hours())
|
|
if h == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", h)
|
|
default:
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
}
|
|
}
|