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": {
|
"/rescan": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Manually trigger rescan",
|
"description": "Manually trigger rescan and wait until the DB is updated",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,7 @@
|
||||||
},
|
},
|
||||||
"/rescan": {
|
"/rescan": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Manually trigger rescan",
|
"description": "Manually trigger rescan and wait until the DB is updated",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -345,7 +345,7 @@ paths:
|
||||||
- network
|
- network
|
||||||
/rescan:
|
/rescan:
|
||||||
get:
|
get:
|
||||||
description: Manually trigger rescan
|
description: Manually trigger rescan and wait until the DB is updated
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,14 @@ func getVersion(c *gin.Context) {
|
||||||
|
|
||||||
// triggerRescan godoc
|
// triggerRescan godoc
|
||||||
// @Summary Rescan all interfaces now
|
// @Summary Rescan all interfaces now
|
||||||
// @Description Manually trigger rescan
|
// @Description Manually trigger rescan and wait until the DB is updated
|
||||||
// @Tags system
|
// @Tags system
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "OK"
|
// @Success 200 {string} string "OK"
|
||||||
// @Router /rescan [get]
|
// @Router /rescan [get]
|
||||||
func triggerRescan(c *gin.Context) {
|
func triggerRescan(c *gin.Context) {
|
||||||
routines.ScanRestart()
|
routines.ScanNow()
|
||||||
c.Status(http.StatusOK)
|
c.IndentedJSON(http.StatusOK, "OK")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConfig godoc
|
// getConfig godoc
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ package arp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -72,17 +76,13 @@ func Scan(ifaces, args string, strs []string) []models.Host {
|
||||||
var foundHosts = []models.Host{}
|
var foundHosts = []models.Host{}
|
||||||
arpArgs = args
|
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, " ")
|
foundHosts = append(foundHosts, parseOutput(text, iface)...)
|
||||||
|
|
||||||
for _, iface := range p {
|
|
||||||
slog.Debug("Scanning interface " + iface)
|
|
||||||
text = scanIface(iface)
|
|
||||||
slog.Debug("Found IPs: \n" + text)
|
|
||||||
|
|
||||||
foundHosts = append(foundHosts, parseOutput(text, iface)...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range strs {
|
for _, s := range strs {
|
||||||
|
|
@ -96,3 +96,192 @@ func Scan(ifaces, args string, strs []string) []models.Host {
|
||||||
|
|
||||||
return foundHosts
|
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
|
package check
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aceberg/WatchYourLAN/internal/models"
|
"github.com/aceberg/WatchYourLAN/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNS - returns DNS names of a host
|
// DNS - returns DNS names of a host
|
||||||
func DNS(host models.Host) (name, dns string) {
|
func DNS(host models.Host) (name, dns string) {
|
||||||
|
names := lookupHostNames(host.IP)
|
||||||
|
|
||||||
dnsNames, _ := net.LookupAddr(host.IP)
|
if len(names) > 0 {
|
||||||
|
name = names[0]
|
||||||
if len(dnsNames) > 0 {
|
dns = strings.Join(names, " ")
|
||||||
name = dnsNames[0]
|
|
||||||
dns = strings.Join(dnsNames, " ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return name, dns
|
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
|
package routines
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aceberg/WatchYourLAN/internal/arp"
|
"github.com/aceberg/WatchYourLAN/internal/arp"
|
||||||
|
|
@ -13,9 +16,21 @@ import (
|
||||||
"github.com/aceberg/WatchYourLAN/internal/prometheus"
|
"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) {
|
func startScan(quit chan bool) {
|
||||||
var lastDate, nowDate, plusDate time.Time
|
var lastDate, nowDate, plusDate time.Time
|
||||||
var foundHosts []models.Host
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|
@ -27,42 +42,56 @@ func startScan(quit chan bool) {
|
||||||
|
|
||||||
if nowDate.After(plusDate) {
|
if nowDate.After(plusDate) {
|
||||||
|
|
||||||
foundHosts = arp.Scan(conf.AppConfig.Ifaces, conf.AppConfig.ArpArgs, conf.AppConfig.ArpStrs)
|
ScanNow()
|
||||||
|
|
||||||
// Make map of found hosts
|
|
||||||
foundHostsMap := make(map[string]models.Host)
|
|
||||||
for _, fHost := range foundHosts {
|
|
||||||
foundHostsMap[fHost.Mac] = fHost
|
|
||||||
}
|
|
||||||
|
|
||||||
compareHosts(foundHostsMap)
|
|
||||||
|
|
||||||
lastDate = time.Now()
|
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")
|
allHosts, ok := gdb.Select("now")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingMACCounts := make(map[string]int)
|
||||||
|
for _, aHost := range allHosts {
|
||||||
|
mac := normalizeMAC(aHost.Mac)
|
||||||
|
if mac != "" {
|
||||||
|
existingMACCounts[mac]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, aHost := range allHosts {
|
for _, aHost := range allHosts {
|
||||||
|
|
||||||
fHost, exists := foundHostsMap[aHost.Mac]
|
mac := normalizeMAC(aHost.Mac)
|
||||||
|
fHost, exists := foundHosts.match(aHost, existingMACCounts[mac] == 1)
|
||||||
if exists {
|
if exists {
|
||||||
|
|
||||||
aHost.Iface = fHost.Iface
|
aHost.Iface = fHost.Iface
|
||||||
aHost.IP = fHost.IP
|
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.Date = fHost.Date
|
||||||
aHost.Now = 1
|
aHost.Now = 1
|
||||||
|
|
||||||
delete(foundHostsMap, aHost.Mac)
|
foundHosts.delete(fHost)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
aHost.Now = 0
|
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
|
notify.Unknown(fHost) // Log and Shoutrrr
|
||||||
|
|
||||||
gdb.Update("now", fHost)
|
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-->
|
<!--Favicon-->
|
||||||
<link rel="icon" type="image/x-icon" href="/fs/public/favicon.png">
|
<link rel="icon" type="image/x-icon" href="/fs/public/favicon.png">
|
||||||
<!-- JS & CSS -->
|
<!-- 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>
|
<script type="module" crossorigin src="/fs/public/assets/index.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/fs/public/assets/index.css">
|
<link rel="stylesheet" crossorigin href="/fs/public/assets/index.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -16,4 +18,4 @@
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
|
|
@ -1,18 +1,21 @@
|
||||||
{
|
{
|
||||||
"name": "watchyourlan",
|
"name": "watchyourlan",
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "watchyourlan",
|
"name": "watchyourlan",
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solid-primitives/scheduled": "^1.5.2",
|
"@solid-primitives/scheduled": "^1.5.2",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"aceberg-bootswatch-fork": "5.3.3-2",
|
||||||
|
"bootstrap-icons": "1.11.3",
|
||||||
"solid-js": "^1.9.5"
|
"solid-js": "^1.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "22.13.10",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"vite-plugin-solid": "^2.11.2"
|
"vite-plugin-solid": "^2.11.2"
|
||||||
|
|
@ -1029,6 +1032,22 @@
|
||||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/babel-plugin-jsx-dom-expressions": {
|
||||||
"version": "0.39.7",
|
"version": "0.39.7",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.39.7.tgz",
|
"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"
|
"@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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.24.4",
|
"version": "4.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
||||||
|
|
@ -1509,6 +1544,13 @@
|
||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solid-primitives/scheduled": "^1.5.2",
|
"@solid-primitives/scheduled": "^1.5.2",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"aceberg-bootswatch-fork": "5.3.3-2",
|
||||||
|
"bootstrap-icons": "1.11.3",
|
||||||
"solid-js": "^1.9.5"
|
"solid-js": "^1.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "22.13.10",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"vite-plugin-solid": "^2.11.2"
|
"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 { editNames, selectedIDs, setEditNames } from "../../functions/exports";
|
||||||
import Filter from "../Filter";
|
import Filter from "../Filter";
|
||||||
import Search from "../Search";
|
import Search from "../Search";
|
||||||
import { getHosts } from "../../functions/atstart";
|
import { getHosts } from "../../functions/atstart";
|
||||||
import { apiDelHost } from "../../functions/api";
|
import { apiDelHost, apiRescan } from "../../functions/api";
|
||||||
|
|
||||||
function CardHead() {
|
function CardHead() {
|
||||||
|
const [rescanning, setRescanning] = createSignal(false);
|
||||||
|
|
||||||
const handleEditNames = (toggle: boolean) => {
|
const handleEditNames = (toggle: boolean) => {
|
||||||
if (!toggle) {
|
if (!toggle) {
|
||||||
|
|
@ -24,6 +25,20 @@ function CardHead() {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRescan = async () => {
|
||||||
|
if (rescanning()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRescanning(true);
|
||||||
|
try {
|
||||||
|
await apiRescan();
|
||||||
|
await getHosts();
|
||||||
|
} finally {
|
||||||
|
setRescanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md mt-1 mb-1">
|
<div class="col-md mt-1 mb-1">
|
||||||
|
|
@ -33,7 +48,18 @@ function CardHead() {
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md mt-1 mb-1">
|
<div class="col-md mt-1 mb-1">
|
||||||
<div class="d-flex justify-content-between">
|
<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
|
<Show
|
||||||
when={editNames()}
|
when={editNames()}
|
||||||
fallback={<button class="btn btn-outline-primary" title="Toggle edit" onClick={[handleEditNames, true]}>Edit</button>}
|
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) {
|
function TableRow(_props: any) {
|
||||||
|
|
||||||
const [name, setName] = createSignal(_props.host.Name);
|
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>;
|
let now = <i class="bi bi-circle-fill" style="color:var(--bs-gray-500);"></i>;
|
||||||
if (_props.host.Now == 1) {
|
if (_props.host.Now == 1) {
|
||||||
|
|
@ -45,7 +64,9 @@ function TableRow(_props: any) {
|
||||||
<td>
|
<td>
|
||||||
<Show
|
<Show
|
||||||
when={editNames()}
|
when={editNames()}
|
||||||
fallback={name()}
|
fallback={
|
||||||
|
<span class={name().trim() === "" ? "text-muted" : ""}>{displayName()}</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<input type="text" class="form-control" value={name()}
|
<input type="text" class="form-control" value={name()}
|
||||||
onInput={e => handleInput(e.target.value)}></input>
|
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 { appConfig, setAppConfig } from "../functions/exports";
|
||||||
import { apiGetConfig } from "../functions/api";
|
import { apiGetConfig } from "../functions/api";
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
|
|
||||||
const [themePath, setThemePath] = createSignal('');
|
|
||||||
const [iconsPath, setIconsPath] = createSignal('');
|
|
||||||
|
|
||||||
const setCurrentTheme = async () => {
|
const setCurrentTheme = async () => {
|
||||||
setAppConfig(await apiGetConfig());
|
setAppConfig(await apiGetConfig());
|
||||||
|
|
||||||
const theme = appConfig().Theme?appConfig().Theme:"sand";
|
const theme = appConfig().Theme?appConfig().Theme:"sand";
|
||||||
const color = appConfig().Color?appConfig().Color:"dark";
|
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 == '') {
|
if (appConfig().NodePath != '') {
|
||||||
setThemePath("https://cdn.jsdelivr.net/npm/aceberg-bootswatch-fork@v5.3.3-2/dist/"+theme+"/bootstrap.min.css");
|
themePath = appConfig().NodePath+"/node_modules/bootswatch/dist/"+theme+"/bootstrap.min.css";
|
||||||
setIconsPath("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css");
|
iconsPath = appConfig().NodePath+"/node_modules/bootstrap-icons/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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
document.documentElement.setAttribute("data-bs-theme", color);
|
||||||
color === "dark"
|
color === "dark"
|
||||||
? document.documentElement.style.setProperty('--transparent-light', '#ffffff15')
|
? document.documentElement.style.setProperty('--transparent-light', '#ffffff15')
|
||||||
: document.documentElement.style.setProperty('--transparent-light', '#00000015');
|
: document.documentElement.style.setProperty('--transparent-light', '#00000015');
|
||||||
}
|
}
|
||||||
setCurrentTheme();
|
|
||||||
|
onMount(() => {
|
||||||
|
setCurrentTheme();
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<nav class="navbar navbar-expand-md navbar-dark bg-primary">
|
||||||
<div class="container-lg">
|
<div class="container-lg">
|
||||||
<a class="navbar-brand" href="/">
|
<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 () => {
|
export const apiGetAllHosts = async () => {
|
||||||
const url = apiPath+'/api/all';
|
const url = apiPath+'/api/all';
|
||||||
|
|
@ -29,6 +29,12 @@ export const apiTestNotify = async () => {
|
||||||
await fetch(url);
|
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) => {
|
export const apiEditHost = async (id:number, name:string, known:string) => {
|
||||||
|
|
||||||
const url = apiPath+'/api/edit/'+id+'/'+name+'/'+known;
|
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();
|
const res = await (await fetch(url)).json();
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { apiGetAllHosts } from "./api";
|
import { apiGetAllHosts, apiGetConfig } from "./api";
|
||||||
import { allHosts, setAllHosts, setBkpHosts, setIfaces } from "./exports";
|
import { appConfig, type Conf, type Host, ifaces as savedIfaces, setAllHosts, setAppConfig, setBkpHosts, setIfaces } from "./exports";
|
||||||
import { filterAtStart, filterFunc } from "./filter";
|
import { filterAtStart, filterFunc } from "./filter";
|
||||||
import { sortAtStart } from "./sort";
|
import { sortAtStart } from "./sort";
|
||||||
|
|
||||||
|
|
@ -13,27 +13,51 @@ export function runAtStart() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHosts() {
|
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);
|
setAllHosts(hosts);
|
||||||
setBkpHosts(hosts);
|
setBkpHosts(hosts);
|
||||||
|
|
||||||
listIfaces();
|
listIfaces(hosts, config);
|
||||||
sortAtStart();
|
sortAtStart();
|
||||||
filterAtStart();
|
filterAtStart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function listIfaces() {
|
function listIfaces(hosts: Host[], config: Conf | null) {
|
||||||
|
const ifaceOptions: string[] = [];
|
||||||
let ifaces:string[] = [];
|
const addIface = (iface: string) => {
|
||||||
|
const trimmed = iface.trim();
|
||||||
for (let host of allHosts) {
|
if (trimmed !== "" && !ifaceOptions.includes(trimmed)) {
|
||||||
if (!ifaces.includes(host.Iface)) {
|
ifaceOptions.push(trimmed);
|
||||||
ifaces.push(host.Iface);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"types": [],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,51 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import solid from 'vite-plugin-solid'
|
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({
|
export default defineConfig({
|
||||||
plugins: [solid()],
|
plugins: [
|
||||||
|
solid(),
|
||||||
|
{
|
||||||
|
name: 'copy-vendor-assets',
|
||||||
|
closeBundle: copyVendorAssets,
|
||||||
|
},
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue