This commit is contained in:
CrisXian 2026-06-23 22:58:25 +08:00 committed by GitHub
commit f800062c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1468 additions and 76 deletions

View file

@ -370,7 +370,7 @@ const docTemplate = `{
},
"/rescan": {
"get": {
"description": "Manually trigger rescan",
"description": "Manually trigger rescan and wait until the DB is updated",
"produces": [
"application/json"
],

View file

@ -363,7 +363,7 @@
},
"/rescan": {
"get": {
"description": "Manually trigger rescan",
"description": "Manually trigger rescan and wait until the DB is updated",
"produces": [
"application/json"
],

View file

@ -345,7 +345,7 @@ paths:
- network
/rescan:
get:
description: Manually trigger rescan
description: Manually trigger rescan and wait until the DB is updated
produces:
- application/json
responses:

View file

@ -25,14 +25,14 @@ func getVersion(c *gin.Context) {
// triggerRescan godoc
// @Summary Rescan all interfaces now
// @Description Manually trigger rescan
// @Description Manually trigger rescan and wait until the DB is updated
// @Tags system
// @Produce json
// @Success 200 {string} string "OK"
// @Router /rescan [get]
func triggerRescan(c *gin.Context) {
routines.ScanRestart()
c.Status(http.StatusOK)
routines.ScanNow()
c.IndentedJSON(http.StatusOK, "OK")
}
// getConfig godoc

View file

@ -2,7 +2,11 @@ package arp
import (
"log/slog"
"net"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
@ -72,17 +76,13 @@ func Scan(ifaces, args string, strs []string) []models.Host {
var foundHosts = []models.Host{}
arpArgs = args
if ifaces != "" {
p = resolveScanInterfaces(ifaces)
for _, iface := range p {
slog.Debug("Scanning interface " + iface)
text = scanIface(iface)
slog.Debug("Found IPs: \n" + text)
p = strings.Split(ifaces, " ")
for _, iface := range p {
slog.Debug("Scanning interface " + iface)
text = scanIface(iface)
slog.Debug("Found IPs: \n" + text)
foundHosts = append(foundHosts, parseOutput(text, iface)...)
}
foundHosts = append(foundHosts, parseOutput(text, iface)...)
}
for _, s := range strs {
@ -96,3 +96,192 @@ func Scan(ifaces, args string, strs []string) []models.Host {
return foundHosts
}
func resolveScanInterfaces(ifaces string) []string {
configured := strings.Fields(ifaces)
available := usableInterfaceMap()
auto := autoScanInterfaces(available)
selected, invalid, usedAuto := selectScanInterfaces(configured, available, auto)
if len(invalid) > 0 {
slog.Warn("Ignoring unavailable scan interfaces", "ifaces", strings.Join(invalid, " "))
}
if usedAuto && len(selected) > 0 {
slog.Warn("Using auto-detected scan interfaces", "ifaces", strings.Join(selected, " "))
}
return selected
}
func selectScanInterfaces(configured []string, available map[string]bool, auto []string) ([]string, []string, bool) {
if len(configured) == 0 {
return uniqueStrings(auto), nil, len(auto) > 0
}
var selected []string
var invalid []string
for _, iface := range configured {
if available[iface] {
selected = appendUniqueString(selected, iface)
continue
}
invalid = appendUniqueString(invalid, iface)
}
if len(selected) > 0 {
return selected, invalid, false
}
return uniqueStrings(auto), invalid, len(auto) > 0
}
func usableInterfaceMap() map[string]bool {
interfaces, err := net.Interfaces()
if err != nil {
slog.Warn("Cannot list network interfaces", "err", err)
return nil
}
available := make(map[string]bool)
for _, iface := range interfaces {
if isUsableInterface(iface) {
available[iface.Name] = true
}
}
return available
}
func isUsableInterface(iface net.Interface) bool {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
return false
}
addrs, err := iface.Addrs()
if err != nil {
slog.Debug("Cannot list interface addresses", "iface", iface.Name, "err", err)
return false
}
for _, addr := range addrs {
if hasUsableIPv4(addr) {
return true
}
}
return false
}
func hasUsableIPv4(addr net.Addr) bool {
ipNet, ok := addr.(*net.IPNet)
if !ok {
return false
}
ip := ipNet.IP.To4()
return ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}
func autoScanInterfaces(available map[string]bool) []string {
if len(available) == 0 {
return nil
}
ifaces := defaultRouteInterfaces(available)
if len(ifaces) > 0 {
return ifaces
}
for iface := range available {
if isVirtualInterface(iface) {
continue
}
ifaces = append(ifaces, iface)
}
sort.Strings(ifaces)
return ifaces
}
func defaultRouteInterfaces(available map[string]bool) []string {
data, err := os.ReadFile("/proc/net/route")
if err != nil {
slog.Debug("Cannot read default routes", "err", err)
return nil
}
var ifaces []string
minMetric := -1
for _, line := range strings.Split(string(data), "\n")[1:] {
fields := strings.Fields(line)
if len(fields) < 7 || fields[1] != "00000000" {
continue
}
iface := fields[0]
if !available[iface] || isVirtualInterface(iface) {
continue
}
metric, err := strconv.Atoi(fields[6])
if err != nil {
metric = 0
}
switch {
case minMetric == -1 || metric < minMetric:
minMetric = metric
ifaces = []string{iface}
case metric == minMetric:
ifaces = appendUniqueString(ifaces, iface)
}
}
sort.Strings(ifaces)
return ifaces
}
func isVirtualInterface(iface string) bool {
prefixes := []string{
"br-",
"docker",
"lxc",
"tailscale",
"tap",
"tun",
"vboxnet",
"veth",
"virbr",
"vmnet",
"wg",
}
for _, prefix := range prefixes {
if strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
var out []string
for _, value := range values {
out = appendUniqueString(out, value)
}
return out
}
func appendUniqueString(values []string, value string) []string {
if value == "" {
return values
}
for _, item := range values {
if item == value {
return values
}
}
return append(values, value)
}

View file

@ -0,0 +1,69 @@
package arp
import (
"reflect"
"testing"
)
func TestSelectScanInterfacesKeepsValidConfiguredInterfaces(t *testing.T) {
available := map[string]bool{
"enp6s0": true,
"wlo1": true,
}
selected, invalid, usedAuto := selectScanInterfaces(
[]string{"enp6s0", "missing0"},
available,
[]string{"wlo1"},
)
if !reflect.DeepEqual(selected, []string{"enp6s0"}) {
t.Fatalf("selected = %#v, want enp6s0", selected)
}
if !reflect.DeepEqual(invalid, []string{"missing0"}) {
t.Fatalf("invalid = %#v, want missing0", invalid)
}
if usedAuto {
t.Fatal("usedAuto = true, want false")
}
}
func TestSelectScanInterfacesFallsBackWhenAllConfiguredInterfacesAreInvalid(t *testing.T) {
available := map[string]bool{
"enp6s0": true,
}
selected, invalid, usedAuto := selectScanInterfaces(
[]string{"enP8p1s0"},
available,
[]string{"enp6s0"},
)
if !reflect.DeepEqual(selected, []string{"enp6s0"}) {
t.Fatalf("selected = %#v, want enp6s0", selected)
}
if !reflect.DeepEqual(invalid, []string{"enP8p1s0"}) {
t.Fatalf("invalid = %#v, want enP8p1s0", invalid)
}
if !usedAuto {
t.Fatal("usedAuto = false, want true")
}
}
func TestSelectScanInterfacesUsesAutoWhenConfigIsBlank(t *testing.T) {
selected, invalid, usedAuto := selectScanInterfaces(
nil,
map[string]bool{"enp6s0": true},
[]string{"enp6s0"},
)
if !reflect.DeepEqual(selected, []string{"enp6s0"}) {
t.Fatalf("selected = %#v, want enp6s0", selected)
}
if len(invalid) != 0 {
t.Fatalf("invalid = %#v, want empty", invalid)
}
if !usedAuto {
t.Fatal("usedAuto = false, want true")
}
}

View file

@ -1,21 +1,385 @@
package check
import (
"bytes"
"context"
"encoding/xml"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os/exec"
"strings"
"time"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// DNS - returns DNS names of a host
func DNS(host models.Host) (name, dns string) {
names := lookupHostNames(host.IP)
dnsNames, _ := net.LookupAddr(host.IP)
if len(dnsNames) > 0 {
name = dnsNames[0]
dns = strings.Join(dnsNames, " ")
if len(names) > 0 {
name = names[0]
dns = strings.Join(names, " ")
}
return name, dns
}
type hostIdentity struct {
names []string
hardware string
}
// EnrichHosts fills host names from local DNS, mDNS and SSDP/UPnP discovery.
func EnrichHosts(hosts []models.Host) []models.Host {
ipSet := make(map[string]struct{})
for _, host := range hosts {
if net.ParseIP(host.IP) != nil {
ipSet[host.IP] = struct{}{}
}
}
avahi := discoverAvahiBrowse(ipSet)
ssdp := discoverSSDP(ipSet)
for i := range hosts {
names := lookupHostNames(hosts[i].IP)
if identity, ok := avahi[hosts[i].IP]; ok {
names = appendUnique(names, identity.names...)
}
if identity, ok := ssdp[hosts[i].IP]; ok {
names = appendUnique(names, identity.names...)
if isUnknownHardware(hosts[i].Hw) && identity.hardware != "" {
hosts[i].Hw = identity.hardware
}
}
if len(names) > 0 {
hosts[i].Name = names[0]
hosts[i].DNS = strings.Join(names, " ")
}
}
return hosts
}
func lookupHostNames(ip string) []string {
names := []string{}
dnsNames, _ := net.LookupAddr(ip)
names = appendUnique(names, dnsNames...)
names = appendUnique(names, getentNames(ip)...)
names = appendUnique(names, avahiNames(ip)...)
return names
}
func getentNames(ip string) []string {
out, ok := runOutput(700*time.Millisecond, "getent", "hosts", ip)
if !ok {
return nil
}
fields := strings.Fields(out)
if len(fields) < 2 {
return nil
}
return fields[1:]
}
func avahiNames(ip string) []string {
out, ok := runOutput(900*time.Millisecond, "avahi-resolve-address", ip)
if !ok {
return nil
}
fields := strings.Fields(out)
if len(fields) < 2 {
return nil
}
return fields[1:]
}
func runOutput(timeout time.Duration, name string, args ...string) (string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil {
return "", false
}
return string(out), true
}
func discoverAvahiBrowse(targetIPs map[string]struct{}) map[string]hostIdentity {
identities := make(map[string]hostIdentity)
if len(targetIPs) == 0 {
return identities
}
out, ok := runOutput(8*time.Second, "avahi-browse", "-artp")
if !ok {
return identities
}
for _, line := range strings.Split(out, "\n") {
if !strings.HasPrefix(line, "=") {
continue
}
parts := strings.Split(line, ";")
if len(parts) < 9 {
continue
}
ip := strings.TrimSpace(parts[7])
if _, ok := targetIPs[ip]; !ok {
continue
}
identity := identities[ip]
identity.names = appendUnique(identity.names, parts[3], parts[6])
identities[ip] = identity
}
return identities
}
func discoverSSDP(targetIPs map[string]struct{}) map[string]hostIdentity {
identities := make(map[string]hostIdentity)
if len(targetIPs) == 0 {
return identities
}
addr, err := net.ResolveUDPAddr("udp4", "239.255.255.250:1900")
if err != nil {
return identities
}
conn, err := net.ListenPacket("udp4", ":0")
if err != nil {
slog.Debug("SSDP listen failed", "err", err)
return identities
}
defer conn.Close()
message := strings.Join([]string{
"M-SEARCH * HTTP/1.1",
"HOST:239.255.255.250:1900",
`MAN:"ssdp:discover"`,
"MX:1",
"ST:ssdp:all",
"",
"",
}, "\r\n")
for i := 0; i < 2; i++ {
_, _ = conn.WriteTo([]byte(message), addr)
}
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
seenLocations := make(map[string]struct{})
for {
buffer := make([]byte, 65535)
n, remote, err := conn.ReadFrom(buffer)
if err != nil {
break
}
responseIP, _, err := net.SplitHostPort(remote.String())
if err != nil {
responseIP = remote.String()
}
headers := parseSSDPHeaders(buffer[:n])
location := strings.TrimSpace(headers["location"])
if location == "" {
continue
}
if _, seen := seenLocations[location]; seen {
continue
}
seenLocations[location] = struct{}{}
ip := matchingLocationIP(location, responseIP, targetIPs)
if ip == "" {
continue
}
identity := identities[ip]
desc := fetchSSDPDescription(location)
identity.names = appendUnique(identity.names, desc["friendlyName"])
identity.hardware = joinNonEmpty(desc["manufacturer"], desc["modelName"], desc["modelNumber"])
identities[ip] = identity
}
return identities
}
func parseSSDPHeaders(payload []byte) map[string]string {
headers := make(map[string]string)
for _, line := range strings.Split(string(payload), "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
headers[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
return headers
}
func matchingLocationIP(location, responseIP string, targetIPs map[string]struct{}) string {
if _, ok := targetIPs[responseIP]; ok {
return responseIP
}
parsed, err := url.Parse(location)
if err != nil || parsed.Hostname() == "" {
return ""
}
locationIP := net.ParseIP(parsed.Hostname())
if locationIP != nil {
ip := locationIP.String()
if _, ok := targetIPs[ip]; ok {
return ip
}
return ""
}
ips, err := net.LookupHost(parsed.Hostname())
if err != nil {
return ""
}
for _, ip := range ips {
if _, ok := targetIPs[ip]; ok {
return ip
}
}
return ""
}
func fetchSSDPDescription(location string) map[string]string {
result := make(map[string]string)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
if err != nil {
return result
}
req.Header.Set("User-Agent", "WatchYourLAN/2.1.4")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return result
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return result
}
return parseSSDPDescription(body)
}
func parseSSDPDescription(body []byte) map[string]string {
result := make(map[string]string)
wanted := map[string]struct{}{
"friendlyName": {},
"manufacturer": {},
"modelName": {},
"modelNumber": {},
}
decoder := xml.NewDecoder(bytes.NewReader(body))
current := ""
for {
token, err := decoder.Token()
if err != nil {
break
}
switch item := token.(type) {
case xml.StartElement:
if _, ok := wanted[item.Name.Local]; ok {
current = item.Name.Local
}
case xml.CharData:
if current != "" {
result[current] = cleanName(string(item))
}
case xml.EndElement:
if item.Name.Local == current {
current = ""
}
}
}
return result
}
func appendUnique(items []string, more ...string) []string {
seen := make(map[string]struct{}, len(items)+len(more))
out := make([]string, 0, len(items)+len(more))
for _, item := range append(items, more...) {
item = cleanName(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func cleanName(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimSuffix(value, ".")
value = strings.Join(strings.Fields(value), " ")
if value == "" || net.ParseIP(value) != nil {
return ""
}
return value
}
func isUnknownHardware(value string) bool {
value = strings.TrimSpace(value)
return value == "" || strings.HasPrefix(value, "(Unknown")
}
func joinNonEmpty(parts ...string) string {
out := []string{}
for _, part := range parts {
part = cleanName(part)
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, " ")
}

View file

@ -1,6 +1,9 @@
package routines
import (
"sort"
"strings"
"sync"
"time"
"github.com/aceberg/WatchYourLAN/internal/arp"
@ -13,9 +16,21 @@ import (
"github.com/aceberg/WatchYourLAN/internal/prometheus"
)
var scanMu sync.Mutex
// ScanNow runs one scan immediately and waits until DB state is updated.
func ScanNow() {
scanMu.Lock()
defer scanMu.Unlock()
foundHosts := arp.Scan(conf.AppConfig.Ifaces, conf.AppConfig.ArpArgs, conf.AppConfig.ArpStrs)
foundHosts = check.EnrichHosts(foundHosts)
compareHosts(newFoundHostIndex(foundHosts))
}
func startScan(quit chan bool) {
var lastDate, nowDate, plusDate time.Time
var foundHosts []models.Host
for {
select {
@ -27,42 +42,56 @@ func startScan(quit chan bool) {
if nowDate.After(plusDate) {
foundHosts = arp.Scan(conf.AppConfig.Ifaces, conf.AppConfig.ArpArgs, conf.AppConfig.ArpStrs)
// Make map of found hosts
foundHostsMap := make(map[string]models.Host)
for _, fHost := range foundHosts {
foundHostsMap[fHost.Mac] = fHost
}
compareHosts(foundHostsMap)
ScanNow()
lastDate = time.Now()
}
time.Sleep(time.Duration(1) * time.Minute)
select {
case <-quit:
return
case <-time.After(time.Duration(1) * time.Minute):
}
}
}
}
func compareHosts(foundHostsMap map[string]models.Host) {
func compareHosts(foundHosts *foundHostIndex) {
allHosts, ok := gdb.Select("now")
if !ok {
return
}
existingMACCounts := make(map[string]int)
for _, aHost := range allHosts {
mac := normalizeMAC(aHost.Mac)
if mac != "" {
existingMACCounts[mac]++
}
}
for _, aHost := range allHosts {
fHost, exists := foundHostsMap[aHost.Mac]
mac := normalizeMAC(aHost.Mac)
fHost, exists := foundHosts.match(aHost, existingMACCounts[mac] == 1)
if exists {
aHost.Iface = fHost.Iface
aHost.IP = fHost.IP
if aHost.Name == "" && fHost.Name != "" {
aHost.Name = fHost.Name
}
if aHost.DNS == "" && fHost.DNS != "" {
aHost.DNS = fHost.DNS
}
if isUnknownHardware(aHost.Hw) && fHost.Hw != "" {
aHost.Hw = fHost.Hw
}
aHost.Date = fHost.Date
aHost.Now = 1
delete(foundHostsMap, aHost.Mac)
foundHosts.delete(fHost)
} else {
aHost.Now = 0
@ -81,11 +110,147 @@ func compareHosts(foundHostsMap map[string]models.Host) {
}
}
for _, fHost := range foundHostsMap {
for _, fHost := range foundHosts.remaining() {
fHost.Name, fHost.DNS = check.DNS(fHost)
if fHost.Name == "" || fHost.DNS == "" {
fHost.Name, fHost.DNS = check.DNS(fHost)
}
notify.Unknown(fHost) // Log and Shoutrrr
gdb.Update("now", fHost)
}
}
type foundHostIndex struct {
hosts map[string]models.Host
keysByIP map[string][]string
keysByMAC map[string][]string
}
func newFoundHostIndex(hosts []models.Host) *foundHostIndex {
index := &foundHostIndex{
hosts: make(map[string]models.Host),
keysByIP: make(map[string][]string),
keysByMAC: make(map[string][]string),
}
for _, host := range hosts {
key := hostIdentityKey(host)
if key == "" {
continue
}
index.hosts[key] = host
if host.IP != "" {
index.keysByIP[host.IP] = appendUnique(index.keysByIP[host.IP], key)
}
mac := normalizeMAC(host.Mac)
if mac != "" {
index.keysByMAC[mac] = appendUnique(index.keysByMAC[mac], key)
}
}
return index
}
func (index *foundHostIndex) match(existing models.Host, allowMACFallback bool) (models.Host, bool) {
key := hostIdentityKey(existing)
if host, ok := index.hosts[key]; ok {
return host, true
}
if existing.IP != "" {
if host, ok := index.only(index.keysByIP[existing.IP]); ok {
return host, true
}
}
mac := normalizeMAC(existing.Mac)
if allowMACFallback && mac != "" {
if host, ok := index.only(index.keysByMAC[mac]); ok {
return host, true
}
}
return models.Host{}, false
}
func (index *foundHostIndex) only(keys []string) (models.Host, bool) {
active := make([]string, 0, len(keys))
for _, key := range keys {
if _, ok := index.hosts[key]; ok {
active = append(active, key)
}
}
if len(active) != 1 {
return models.Host{}, false
}
host, ok := index.hosts[active[0]]
return host, ok
}
func (index *foundHostIndex) delete(host models.Host) {
key := hostIdentityKey(host)
delete(index.hosts, key)
}
func (index *foundHostIndex) remaining() []models.Host {
keys := make([]string, 0, len(index.hosts))
for key := range index.hosts {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
return index.hosts[keys[i]].IP < index.hosts[keys[j]].IP
})
hosts := make([]models.Host, 0, len(keys))
for _, key := range keys {
hosts = append(hosts, index.hosts[key])
}
return hosts
}
func hostIdentityKey(host models.Host) string {
ip := strings.TrimSpace(host.IP)
mac := normalizeMAC(host.Mac)
if mac != "" && ip != "" {
return mac + "|" + ip
}
if mac != "" {
return mac
}
return ip
}
func normalizeMAC(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func appendUnique(items []string, more ...string) []string {
seen := make(map[string]struct{}, len(items)+len(more))
out := make([]string, 0, len(items)+len(more))
for _, item := range append(items, more...) {
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func isUnknownHardware(value string) bool {
value = strings.TrimSpace(value)
return value == "" || strings.HasPrefix(value, "(Unknown")
}

View file

@ -0,0 +1,57 @@
package routines
import (
"testing"
"github.com/aceberg/WatchYourLAN/internal/models"
)
func TestFoundHostIndexKeepsSameMACDifferentIPs(t *testing.T) {
index := newFoundHostIndex([]models.Host{
{IP: "192.168.57.146", Mac: "48:b0:2d:eb:f5:15"},
{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"},
})
host, ok := index.match(models.Host{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, false)
if !ok {
t.Fatal("expected exact MAC+IP match")
}
if host.IP != "192.168.57.209" {
t.Fatalf("matched wrong IP: %s", host.IP)
}
index.delete(host)
remaining := index.remaining()
if len(remaining) != 1 {
t.Fatalf("expected one remaining host, got %d", len(remaining))
}
if remaining[0].IP != "192.168.57.146" {
t.Fatalf("expected 192.168.57.146 to remain, got %s", remaining[0].IP)
}
}
func TestFoundHostIndexUsesMACForSingleAddressChange(t *testing.T) {
index := newFoundHostIndex([]models.Host{
{IP: "192.168.57.210", Mac: "48:b0:2d:eb:f5:15"},
})
host, ok := index.match(models.Host{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, true)
if !ok {
t.Fatal("expected single MAC match for address change")
}
if host.IP != "192.168.57.210" {
t.Fatalf("matched wrong IP: %s", host.IP)
}
}
func TestFoundHostIndexDoesNotUseMACFallbackForKnownMultiIPHost(t *testing.T) {
index := newFoundHostIndex([]models.Host{
{IP: "192.168.57.146", Mac: "48:b0:2d:eb:f5:15"},
})
_, ok := index.match(models.Host{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, false)
if ok {
t.Fatal("did not expect MAC fallback when same MAC is already known on multiple IPs")
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,8 @@
<!--Favicon-->
<link rel="icon" type="image/x-icon" href="/fs/public/favicon.png">
<!-- JS & CSS -->
<link id="watchyourlan-icons-css" rel="stylesheet" href="/fs/public/vendor/bootstrap-icons/font/bootstrap-icons.min.css">
<link id="watchyourlan-theme-css" rel="stylesheet" href="/fs/public/vendor/aceberg-bootswatch-fork/dist/sand/bootstrap.min.css">
<script type="module" crossorigin src="/fs/public/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/fs/public/assets/index.css">
</head>
@ -16,4 +18,4 @@
<div id="root"></div>
</body>
</html>
{{ end }}
{{ end }}

View file

@ -1,18 +1,21 @@
{
"name": "watchyourlan",
"version": "2.1.3",
"version": "2.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watchyourlan",
"version": "2.1.3",
"version": "2.1.4",
"dependencies": {
"@solid-primitives/scheduled": "^1.5.2",
"@solidjs/router": "^0.15.3",
"aceberg-bootswatch-fork": "5.3.3-2",
"bootstrap-icons": "1.11.3",
"solid-js": "^1.9.5"
},
"devDependencies": {
"@types/node": "22.13.10",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vite-plugin-solid": "^2.11.2"
@ -1029,6 +1032,22 @@
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"dev": true
},
"node_modules/@types/node": {
"version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/aceberg-bootswatch-fork": {
"version": "5.3.3-2",
"resolved": "https://registry.npmjs.org/aceberg-bootswatch-fork/-/aceberg-bootswatch-fork-5.3.3-2.tgz",
"integrity": "sha512-DE7EB59n718Q+niRQhR1SQf9e6p/Q+U2yBtIbsPCoK1TGgj+n2VYHtcDIyXsbKofWB9D3T/zr5Gkx8EHX9n0jA==",
"license": "MIT"
},
"node_modules/babel-plugin-jsx-dom-expressions": {
"version": "0.39.7",
"resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.39.7.tgz",
@ -1070,6 +1089,22 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/bootstrap-icons": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz",
"integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
@ -1509,6 +1544,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View file

@ -11,9 +11,12 @@
"dependencies": {
"@solid-primitives/scheduled": "^1.5.2",
"@solidjs/router": "^0.15.3",
"aceberg-bootswatch-fork": "5.3.3-2",
"bootstrap-icons": "1.11.3",
"solid-js": "^1.9.5"
},
"devDependencies": {
"@types/node": "22.13.10",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vite-plugin-solid": "^2.11.2"

View file

@ -1,11 +1,12 @@
import { Show } from "solid-js";
import { Show, createSignal } from "solid-js";
import { editNames, selectedIDs, setEditNames } from "../../functions/exports";
import Filter from "../Filter";
import Search from "../Search";
import { getHosts } from "../../functions/atstart";
import { apiDelHost } from "../../functions/api";
import { apiDelHost, apiRescan } from "../../functions/api";
function CardHead() {
const [rescanning, setRescanning] = createSignal(false);
const handleEditNames = (toggle: boolean) => {
if (!toggle) {
@ -24,6 +25,20 @@ function CardHead() {
window.location.href = '/';
};
const handleRescan = async () => {
if (rescanning()) {
return;
}
setRescanning(true);
try {
await apiRescan();
await getHosts();
} finally {
setRescanning(false);
}
};
return (
<div class="row">
<div class="col-md mt-1 mb-1">
@ -33,7 +48,18 @@ function CardHead() {
</div>
<div class="col-md mt-1 mb-1">
<div class="d-flex justify-content-between">
<Search></Search>
<div class="d-flex gap-2">
<Search></Search>
<button
class="btn btn-outline-primary"
title="Rescan now"
onClick={handleRescan}
disabled={rescanning()}
style="width: 2.5rem;"
>
<i class={rescanning() ? "spinner-border spinner-border-sm" : "bi bi-arrow-clockwise"}></i>
</button>
</div>
<Show
when={editNames()}
fallback={<button class="btn btn-outline-primary" title="Toggle edit" onClick={[handleEditNames, true]}>Edit</button>}

View file

@ -7,6 +7,25 @@ import { debounce } from "@solid-primitives/scheduled";
function TableRow(_props: any) {
const [name, setName] = createSignal(_props.host.Name);
const displayName = () => {
const currentName = name().trim();
if (currentName !== "") {
return currentName;
}
const hardware = (_props.host.Hw || "").trim();
if (hardware !== "" && !hardware.startsWith("(Unknown")) {
return hardware;
}
const mac = (_props.host.Mac || "").trim();
if (mac !== "") {
return "Unknown " + mac.slice(-5);
}
return "Unknown";
};
let now = <i class="bi bi-circle-fill" style="color:var(--bs-gray-500);"></i>;
if (_props.host.Now == 1) {
@ -45,7 +64,9 @@ function TableRow(_props: any) {
<td>
<Show
when={editNames()}
fallback={name()}
fallback={
<span class={name().trim() === "" ? "text-muted" : ""}>{displayName()}</span>
}
>
<input type="text" class="form-control" value={name()}
onInput={e => handleInput(e.target.value)}></input>

View file

@ -1,37 +1,39 @@
import { createSignal } from "solid-js";
import { onMount } from "solid-js";
import { appConfig, setAppConfig } from "../functions/exports";
import { apiGetConfig } from "../functions/api";
function Header() {
const [themePath, setThemePath] = createSignal('');
const [iconsPath, setIconsPath] = createSignal('');
const setCurrentTheme = async () => {
setAppConfig(await apiGetConfig());
const theme = appConfig().Theme?appConfig().Theme:"sand";
const color = appConfig().Color?appConfig().Color:"dark";
let themePath = "/fs/public/vendor/aceberg-bootswatch-fork/dist/"+theme+"/bootstrap.min.css";
let iconsPath = "/fs/public/vendor/bootstrap-icons/font/bootstrap-icons.min.css";
if (appConfig().NodePath == '') {
setThemePath("https://cdn.jsdelivr.net/npm/aceberg-bootswatch-fork@v5.3.3-2/dist/"+theme+"/bootstrap.min.css");
setIconsPath("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css");
} else {
setThemePath(appConfig().NodePath+"/node_modules/bootswatch/dist/"+theme+"/bootstrap.min.css");
setIconsPath(appConfig().NodePath+"/node_modules/bootstrap-icons/font/bootstrap-icons.css");
if (appConfig().NodePath != '') {
themePath = appConfig().NodePath+"/node_modules/bootswatch/dist/"+theme+"/bootstrap.min.css";
iconsPath = appConfig().NodePath+"/node_modules/bootstrap-icons/font/bootstrap-icons.css";
}
const themeLink = document.getElementById("watchyourlan-theme-css") as HTMLLinkElement | null;
const iconsLink = document.getElementById("watchyourlan-icons-css") as HTMLLinkElement | null;
themeLink?.setAttribute("href", themePath);
iconsLink?.setAttribute("href", iconsPath);
document.documentElement.setAttribute("data-bs-theme", color);
color === "dark"
? document.documentElement.style.setProperty('--transparent-light', '#ffffff15')
: document.documentElement.style.setProperty('--transparent-light', '#00000015');
}
setCurrentTheme();
onMount(() => {
setCurrentTheme();
});
return (
<>
<link rel="stylesheet" href={iconsPath()}></link> {/* icons */}
<link rel="stylesheet" href={themePath()}></link> {/* theme */}
<nav class="navbar navbar-expand-md navbar-dark bg-primary">
<div class="container-lg">
<a class="navbar-brand" href="/">

View file

@ -1,4 +1,4 @@
export const apiPath = 'http://0.0.0.0:8840';
export const apiPath = '';
export const apiGetAllHosts = async () => {
const url = apiPath+'/api/all';
@ -29,6 +29,12 @@ export const apiTestNotify = async () => {
await fetch(url);
};
export const apiRescan = async () => {
const url = apiPath+'/api/rescan';
await fetch(url);
};
export const apiEditHost = async (id:number, name:string, known:string) => {
const url = apiPath+'/api/edit/'+id+'/'+name+'/'+known;
@ -81,4 +87,4 @@ export const apiWOL = async (mac:string) => {
const res = await (await fetch(url)).json();
return res;
};
};

View file

@ -1,5 +1,5 @@
import { apiGetAllHosts } from "./api";
import { allHosts, setAllHosts, setBkpHosts, setIfaces } from "./exports";
import { apiGetAllHosts, apiGetConfig } from "./api";
import { appConfig, type Conf, type Host, ifaces as savedIfaces, setAllHosts, setAppConfig, setBkpHosts, setIfaces } from "./exports";
import { filterAtStart, filterFunc } from "./filter";
import { sortAtStart } from "./sort";
@ -13,27 +13,51 @@ export function runAtStart() {
}
export async function getHosts() {
const hosts = await apiGetAllHosts();
const [hosts, config] = await Promise.all([
apiGetAllHosts(),
apiGetConfig().catch(() => null),
]);
if (hosts !== null && hosts.length > 0) {
if (config !== null) {
setAppConfig(config);
}
if (hosts !== null) {
setAllHosts(hosts);
setBkpHosts(hosts);
listIfaces();
listIfaces(hosts, config);
sortAtStart();
filterAtStart();
}
}
function listIfaces() {
let ifaces:string[] = [];
for (let host of allHosts) {
if (!ifaces.includes(host.Iface)) {
ifaces.push(host.Iface);
function listIfaces(hosts: Host[], config: Conf | null) {
const ifaceOptions: string[] = [];
const addIface = (iface: string) => {
const trimmed = iface.trim();
if (trimmed !== "" && !ifaceOptions.includes(trimmed)) {
ifaceOptions.push(trimmed);
}
};
for (let host of hosts) {
addIface(host.Iface);
}
setIfaces(ifaces);
}
const configuredIfaces = config?.Ifaces || appConfig().Ifaces;
for (let iface of configuredIfaces.split(/\s+/)) {
addIface(iface);
}
if (ifaceOptions.length > 0) {
setIfaces(ifaceOptions);
return;
}
if (savedIfaces().length > 0) {
return;
}
setIfaces([]);
}

View file

@ -5,6 +5,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": [],
"skipLibCheck": true,
/* Bundler mode */

View file

@ -3,6 +3,7 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"types": ["node"],
"module": "ESNext",
"skipLibCheck": true,

View file

@ -1,8 +1,51 @@
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import { copyFileSync, cpSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = dirname(fileURLToPath(import.meta.url))
function copyVendorAssets() {
const vendorDir = resolve(rootDir, 'dist/vendor')
rmSync(vendorDir, { recursive: true, force: true })
mkdirSync(vendorDir, { recursive: true })
const themesDir = resolve(rootDir, 'node_modules/aceberg-bootswatch-fork/dist')
for (const theme of readdirSync(themesDir, { withFileTypes: true })) {
if (!theme.isDirectory()) {
continue
}
const targetDir = resolve(vendorDir, 'aceberg-bootswatch-fork/dist', theme.name)
mkdirSync(targetDir, { recursive: true })
copyFileSync(
resolve(themesDir, theme.name, 'bootstrap.min.css'),
resolve(targetDir, 'bootstrap.min.css'),
)
}
mkdirSync(resolve(vendorDir, 'bootstrap-icons/font'), { recursive: true })
cpSync(
resolve(rootDir, 'node_modules/bootstrap-icons/font/bootstrap-icons.min.css'),
resolve(vendorDir, 'bootstrap-icons/font/bootstrap-icons.min.css'),
)
cpSync(
resolve(rootDir, 'node_modules/bootstrap-icons/font/fonts'),
resolve(vendorDir, 'bootstrap-icons/font/fonts'),
{ recursive: true },
)
}
export default defineConfig({
plugins: [solid()],
plugins: [
solid(),
{
name: 'copy-vendor-assets',
closeBundle: copyVendorAssets,
},
],
build: {
rollupOptions: {
output: {