2026-04-15 18:56:12 +01:00
|
|
|
package display
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"html"
|
|
|
|
|
"net"
|
|
|
|
|
"os"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-04-15 19:16:45 +01:00
|
|
|
|
|
|
|
|
"forge.lthn.ai/core/gui/pkg/p2p"
|
2026-04-15 18:56:12 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type NetworkInterfaceState struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Index int `json:"index"`
|
|
|
|
|
MTU int `json:"mtu"`
|
|
|
|
|
HardwareAddr string `json:"hardware_addr,omitempty"`
|
|
|
|
|
Flags []string `json:"flags,omitempty"`
|
|
|
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
|
|
|
Up bool `json:"up"`
|
|
|
|
|
Loopback bool `json:"loopback"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:16:45 +01:00
|
|
|
type NetworkPeerState struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Topic string `json:"topic"`
|
|
|
|
|
Connected bool `json:"connected"`
|
|
|
|
|
SeenAt time.Time `json:"seen_at"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 18:56:12 +01:00
|
|
|
type NetworkState struct {
|
|
|
|
|
Hostname string `json:"hostname"`
|
|
|
|
|
Interfaces []NetworkInterfaceState `json:"interfaces"`
|
2026-04-15 19:16:45 +01:00
|
|
|
Peers []NetworkPeerState `json:"peers,omitempty"`
|
2026-04-15 18:56:12 +01:00
|
|
|
ObservedAt time.Time `json:"observed_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) networkState() NetworkState {
|
|
|
|
|
state := NetworkState{
|
|
|
|
|
Hostname: hostname(),
|
|
|
|
|
Interfaces: make([]NetworkInterfaceState, 0),
|
|
|
|
|
ObservedAt: time.Now().UTC(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interfaces, err := net.Interfaces()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sort.Slice(interfaces, func(i, j int) bool {
|
|
|
|
|
return strings.ToLower(interfaces[i].Name) < strings.ToLower(interfaces[j].Name)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for _, iface := range interfaces {
|
|
|
|
|
addresses := make([]string, 0)
|
|
|
|
|
if addrs, err := iface.Addrs(); err == nil {
|
|
|
|
|
for _, addr := range addrs {
|
|
|
|
|
addresses = append(addresses, addr.String())
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(addresses)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.Interfaces = append(state.Interfaces, NetworkInterfaceState{
|
|
|
|
|
Name: iface.Name,
|
|
|
|
|
Index: iface.Index,
|
|
|
|
|
MTU: iface.MTU,
|
|
|
|
|
HardwareAddr: iface.HardwareAddr.String(),
|
|
|
|
|
Flags: interfaceFlags(iface.Flags),
|
|
|
|
|
Addresses: addresses,
|
|
|
|
|
Up: iface.Flags&net.FlagUp != 0,
|
|
|
|
|
Loopback: iface.Flags&net.FlagLoopback != 0,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:16:45 +01:00
|
|
|
state.Peers = s.p2pPeers()
|
2026-04-15 18:56:12 +01:00
|
|
|
return state
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:16:45 +01:00
|
|
|
type peerLister interface {
|
|
|
|
|
Peers() []p2p.Peer
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) p2pPeers() []NetworkPeerState {
|
|
|
|
|
if s == nil || s.Core() == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, serviceName := range []string{"p2p", "network"} {
|
|
|
|
|
serviceResult := s.Core().Service(serviceName)
|
|
|
|
|
if !serviceResult.OK || serviceResult.Value == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
lister, ok := serviceResult.Value.(peerLister)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
peers := lister.Peers()
|
|
|
|
|
if len(peers) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
peerStates := make([]NetworkPeerState, 0, len(peers))
|
|
|
|
|
for _, peer := range peers {
|
|
|
|
|
peerStates = append(peerStates, NetworkPeerState{
|
|
|
|
|
ID: peer.ID,
|
|
|
|
|
Topic: peer.Topic,
|
|
|
|
|
Connected: peer.Connected,
|
|
|
|
|
SeenAt: peer.SeenAt,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(peerStates, func(i, j int) bool {
|
|
|
|
|
if peerStates[i].SeenAt.Equal(peerStates[j].SeenAt) {
|
|
|
|
|
return strings.ToLower(peerStates[i].ID) < strings.ToLower(peerStates[j].ID)
|
|
|
|
|
}
|
|
|
|
|
return peerStates[i].SeenAt.After(peerStates[j].SeenAt)
|
|
|
|
|
})
|
|
|
|
|
return peerStates
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 18:56:12 +01:00
|
|
|
func (s *Service) renderNetworkPage(state NetworkState) string {
|
|
|
|
|
var builder strings.Builder
|
|
|
|
|
builder.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>core://network</title><style>")
|
|
|
|
|
builder.WriteString("body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#07111f;color:#e2e8f0;margin:0}")
|
|
|
|
|
builder.WriteString("header{padding:20px;border-bottom:1px solid #203047;background:linear-gradient(180deg,#0f172a,#07111f)}")
|
|
|
|
|
builder.WriteString("main{padding:20px;display:grid;gap:16px}section{background:#0b1220;border:1px solid #203047;border-radius:16px;padding:16px}")
|
|
|
|
|
builder.WriteString("ul{list-style:none;padding:0;margin:0;display:grid;gap:12px}.iface{padding:12px;border:1px solid #203047;border-radius:12px;background:#020617}")
|
|
|
|
|
builder.WriteString(".meta{color:#94a3b8}.name{font-weight:700;color:#7dd3fc}code{background:#111827;border-radius:8px;padding:2px 6px}")
|
|
|
|
|
builder.WriteString("</style></head><body><header><strong>core://network</strong><div class=\"meta\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(state.Hostname))
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
builder.WriteString(html.EscapeString(state.ObservedAt.Format(time.RFC3339)))
|
|
|
|
|
builder.WriteString("</div></header><main><section><ul>")
|
|
|
|
|
|
|
|
|
|
if len(state.Interfaces) == 0 {
|
|
|
|
|
builder.WriteString("<li class=\"meta\">No network interfaces were detected.</li>")
|
|
|
|
|
} else {
|
|
|
|
|
for _, iface := range state.Interfaces {
|
|
|
|
|
builder.WriteString("<li class=\"iface\"><div class=\"name\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(iface.Name))
|
|
|
|
|
builder.WriteString("</div><div class=\"meta\">Index ")
|
|
|
|
|
builder.WriteString(fmt.Sprintf("%d", iface.Index))
|
|
|
|
|
builder.WriteString(" · MTU ")
|
|
|
|
|
builder.WriteString(fmt.Sprintf("%d", iface.MTU))
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
if iface.Up {
|
|
|
|
|
builder.WriteString("up")
|
|
|
|
|
} else {
|
|
|
|
|
builder.WriteString("down")
|
|
|
|
|
}
|
|
|
|
|
if iface.Loopback {
|
|
|
|
|
builder.WriteString(" · loopback")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</div>")
|
|
|
|
|
if len(iface.Addresses) > 0 {
|
|
|
|
|
builder.WriteString("<pre>")
|
|
|
|
|
builder.WriteString(html.EscapeString(strings.Join(iface.Addresses, "\n")))
|
|
|
|
|
builder.WriteString("</pre>")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</li>")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:16:45 +01:00
|
|
|
if len(state.Peers) > 0 {
|
|
|
|
|
builder.WriteString("</ul></section><section><div class=\"meta\">Registered peers</div><ul>")
|
|
|
|
|
for _, peer := range state.Peers {
|
|
|
|
|
builder.WriteString("<li class=\"iface\"><div class=\"name\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(peer.ID))
|
|
|
|
|
builder.WriteString("</div><div class=\"meta\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(peer.Topic))
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
if peer.Connected {
|
|
|
|
|
builder.WriteString("connected")
|
|
|
|
|
} else {
|
|
|
|
|
builder.WriteString("disconnected")
|
|
|
|
|
}
|
|
|
|
|
if !peer.SeenAt.IsZero() {
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
builder.WriteString(html.EscapeString(peer.SeenAt.Format(time.RFC3339)))
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</div></li>")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</ul></section>")
|
|
|
|
|
} else {
|
|
|
|
|
builder.WriteString("</ul></section>")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</main></body></html>")
|
2026-04-15 18:56:12 +01:00
|
|
|
return builder.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) renderNetworkInterfacePage(state NetworkState, iface NetworkInterfaceState) string {
|
|
|
|
|
var builder strings.Builder
|
|
|
|
|
builder.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>core://network/")
|
|
|
|
|
builder.WriteString(html.EscapeString(iface.Name))
|
|
|
|
|
builder.WriteString("</title><style>")
|
|
|
|
|
builder.WriteString("body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#07111f;color:#e2e8f0;margin:0}")
|
|
|
|
|
builder.WriteString("header{padding:20px;border-bottom:1px solid #203047;background:linear-gradient(180deg,#0f172a,#07111f)}")
|
|
|
|
|
builder.WriteString("main{padding:20px;display:grid;gap:16px}section{background:#0b1220;border:1px solid #203047;border-radius:16px;padding:16px}")
|
|
|
|
|
builder.WriteString("pre{margin:0;white-space:pre-wrap;word-break:break-word}code{background:#111827;border-radius:8px;padding:2px 6px}")
|
|
|
|
|
builder.WriteString(".meta{color:#94a3b8}.name{font-weight:700;color:#7dd3fc}")
|
|
|
|
|
builder.WriteString("</style></head><body><header><strong>core://network/")
|
|
|
|
|
builder.WriteString(html.EscapeString(iface.Name))
|
|
|
|
|
builder.WriteString("</strong><div class=\"meta\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(state.Hostname))
|
|
|
|
|
builder.WriteString("</div></header><main><section><div class=\"name\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(iface.Name))
|
|
|
|
|
builder.WriteString("</div><div class=\"meta\">Index ")
|
|
|
|
|
builder.WriteString(fmt.Sprintf("%d", iface.Index))
|
|
|
|
|
builder.WriteString(" · MTU ")
|
|
|
|
|
builder.WriteString(fmt.Sprintf("%d", iface.MTU))
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
if iface.Up {
|
|
|
|
|
builder.WriteString("up")
|
|
|
|
|
} else {
|
|
|
|
|
builder.WriteString("down")
|
|
|
|
|
}
|
|
|
|
|
if iface.Loopback {
|
|
|
|
|
builder.WriteString(" · loopback")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</div><pre>")
|
|
|
|
|
builder.WriteString(html.EscapeString(strings.Join(iface.Addresses, "\n")))
|
|
|
|
|
builder.WriteString("</pre><div class=\"meta\">Flags: ")
|
|
|
|
|
if len(iface.Flags) == 0 {
|
|
|
|
|
builder.WriteString("none")
|
|
|
|
|
} else {
|
|
|
|
|
builder.WriteString(html.EscapeString(strings.Join(iface.Flags, ", ")))
|
|
|
|
|
}
|
2026-04-15 19:16:45 +01:00
|
|
|
builder.WriteString("</div></section>")
|
|
|
|
|
if len(state.Peers) > 0 {
|
|
|
|
|
builder.WriteString("<section><div class=\"meta\">Registered peers</div><ul>")
|
|
|
|
|
for _, peer := range state.Peers {
|
|
|
|
|
builder.WriteString("<li class=\"iface\"><div class=\"name\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(peer.ID))
|
|
|
|
|
builder.WriteString("</div><div class=\"meta\">")
|
|
|
|
|
builder.WriteString(html.EscapeString(peer.Topic))
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
if peer.Connected {
|
|
|
|
|
builder.WriteString("connected")
|
|
|
|
|
} else {
|
|
|
|
|
builder.WriteString("disconnected")
|
|
|
|
|
}
|
|
|
|
|
if !peer.SeenAt.IsZero() {
|
|
|
|
|
builder.WriteString(" · ")
|
|
|
|
|
builder.WriteString(html.EscapeString(peer.SeenAt.Format(time.RFC3339)))
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</div></li>")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</ul></section>")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("</main></body></html>")
|
2026-04-15 18:56:12 +01:00
|
|
|
return builder.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func interfaceFlags(flags net.Flags) []string {
|
|
|
|
|
values := make([]string, 0, 4)
|
|
|
|
|
if flags&net.FlagUp != 0 {
|
|
|
|
|
values = append(values, "up")
|
|
|
|
|
}
|
|
|
|
|
if flags&net.FlagBroadcast != 0 {
|
|
|
|
|
values = append(values, "broadcast")
|
|
|
|
|
}
|
|
|
|
|
if flags&net.FlagLoopback != 0 {
|
|
|
|
|
values = append(values, "loopback")
|
|
|
|
|
}
|
|
|
|
|
if flags&net.FlagPointToPoint != 0 {
|
|
|
|
|
values = append(values, "point-to-point")
|
|
|
|
|
}
|
|
|
|
|
if flags&net.FlagMulticast != 0 {
|
|
|
|
|
values = append(values, "multicast")
|
|
|
|
|
}
|
|
|
|
|
if flags&net.FlagRunning != 0 {
|
|
|
|
|
values = append(values, "running")
|
|
|
|
|
}
|
|
|
|
|
return values
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hostname() string {
|
|
|
|
|
name, err := os.Hostname()
|
|
|
|
|
if err != nil || strings.TrimSpace(name) == "" {
|
|
|
|
|
return "localhost"
|
|
|
|
|
}
|
|
|
|
|
return name
|
|
|
|
|
}
|