Merge 5a8856350d into ca9f644bcc
This commit is contained in:
commit
f800062c27
55 changed files with 1468 additions and 76 deletions
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@
|
|||
},
|
||||
"/rescan": {
|
||||
"get": {
|
||||
"description": "Manually trigger rescan",
|
||||
"description": "Manually trigger rescan and wait until the DB is updated",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
69
backend/internal/arp/arpscan_test.go
Normal file
69
backend/internal/arp/arpscan_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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, " ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
57
backend/internal/routines/scan-routine_test.go
Normal file
57
backend/internal/routines/scan-routine_test.go
Normal 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
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/cerulean/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/cerulean/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/cosmo/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/cosmo/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/cyborg/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/cyborg/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/darkly/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/darkly/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/emerald/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/emerald/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/flatly/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/flatly/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/grass/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/grass/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/grayscale/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/grayscale/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/journal/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/journal/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/litera/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/litera/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/lumen/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/lumen/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/lux/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/lux/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/materia/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/materia/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/minty/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/minty/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/morph/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/morph/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/ocean/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/ocean/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/pulse/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/pulse/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/quartz/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/quartz/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/sand/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/sand/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/sandstone/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/sandstone/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/simplex/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/simplex/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/sketchy/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/sketchy/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/slate/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/slate/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/solar/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/solar/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/spacelab/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/spacelab/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/superhero/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/superhero/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/united/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/united/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/vapor/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/vapor/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/wood/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/wood/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/yeti/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/yeti/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/zephyr/bootstrap.min.css
vendored
Normal file
12
backend/internal/web/public/vendor/aceberg-bootswatch-fork/dist/zephyr/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
5
backend/internal/web/public/vendor/bootstrap-icons/font/bootstrap-icons.min.css
vendored
Normal file
5
backend/internal/web/public/vendor/bootstrap-icons/font/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/internal/web/public/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff
vendored
Normal file
BIN
backend/internal/web/public/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff
vendored
Normal file
Binary file not shown.
BIN
backend/internal/web/public/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff2
vendored
Normal file
BIN
backend/internal/web/public/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff2
vendored
Normal file
Binary file not shown.
|
|
@ -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 }}
|
||||
|
|
|
|||
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="/">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue