├── .version
├── backend
├── internal
│ ├── web
│ │ ├── public
│ │ │ ├── version
│ │ │ ├── favicon.png
│ │ │ └── assets
│ │ │ │ ├── index.css
│ │ │ │ ├── MacHistory.js
│ │ │ │ ├── History.js
│ │ │ │ ├── HostPage.js
│ │ │ │ └── Config.js
│ │ ├── index.go
│ │ ├── templates
│ │ │ └── index.html
│ │ └── webgui.go
│ ├── check
│ │ ├── error.go
│ │ ├── network.go
│ │ └── file.go
│ ├── api
│ │ ├── functions.go
│ │ ├── api-network.go
│ │ ├── routes.go
│ │ ├── api-history.go
│ │ ├── config.go
│ │ ├── api-system.go
│ │ └── api-hosts.go
│ ├── portscan
│ │ └── scan.go
│ ├── conf
│ │ ├── start.go
│ │ ├── write.go
│ │ └── read.go
│ ├── routines
│ │ ├── trim-history.go
│ │ ├── restart-scan.go
│ │ └── scan-routine.go
│ ├── gdb
│ │ ├── edit.go
│ │ ├── select.go
│ │ └── start.go
│ ├── notify
│ │ └── shout.go
│ ├── models
│ │ └── models.go
│ ├── prometheus
│ │ └── prometheus.go
│ ├── influx
│ │ └── influx.go
│ └── arp
│ │ └── arpscan.go
├── configs
│ ├── postinstall.sh
│ ├── install.sh
│ ├── watchyourlan
│ └── watchyourlan.service
├── Makefile
├── LICENSE
├── cmd
│ └── WatchYourLAN
│ │ └── main.go
├── .goreleaser.yaml
├── go.mod
└── docs
│ ├── swagger.yaml
│ ├── swagger.json
│ └── docs.go
├── .gitignore
├── frontend
├── src
│ ├── vite-env.d.ts
│ ├── index.tsx
│ ├── components
│ │ ├── Search.tsx
│ │ ├── HistShow.tsx
│ │ ├── Config
│ │ │ ├── Donate.tsx
│ │ │ ├── Prometheus.tsx
│ │ │ ├── Influx.tsx
│ │ │ ├── About.tsx
│ │ │ ├── Basic.tsx
│ │ │ └── Scan.tsx
│ │ ├── MacHistory.tsx
│ │ ├── HostPage
│ │ │ ├── HistCard.tsx
│ │ │ ├── Ping.tsx
│ │ │ └── HostCard.tsx
│ │ ├── Body
│ │ │ ├── TableHead.tsx
│ │ │ ├── CardHead.tsx
│ │ │ └── TableRow.tsx
│ │ ├── Filter.tsx
│ │ └── Header.tsx
│ ├── functions
│ │ ├── history.ts
│ │ ├── search.ts
│ │ ├── atstart.ts
│ │ ├── filter.ts
│ │ ├── sort.ts
│ │ ├── api.ts
│ │ └── exports.ts
│ ├── App.css
│ ├── pages
│ │ ├── Config.tsx
│ │ ├── Body.tsx
│ │ ├── HostPage.tsx
│ │ └── History.tsx
│ └── App.tsx
├── tsconfig.json
├── .gitignore
├── Makefile
├── vite.config.ts
├── index.html
├── package.json
├── tsconfig.node.json
├── tsconfig.app.json
└── README.md
├── assets
├── logo.png
├── Screenshot_1.png
├── Screenshot_2.png
├── Screenshot_3.png
├── Screenshot_4.png
├── Screenshot_5.png
├── Screenshot_v0.6.png
├── Screenshot_Gotify.png
├── Screenshot 2024-08-29 at 01-41-12 WatchYourLAN.png
└── Screenshot 2024-08-29 at 11-17-59 WatchYourLAN.png
├── .github
├── FUNDING.yml
└── workflows
│ ├── readme-docker.yml
│ ├── binary-release.yml
│ ├── dev-docker-io.yml
│ ├── new-dev-docker.yml
│ └── main-docker-all.yml
├── Dockerfile
├── FAQ.md
├── LICENSE
├── docker-compose.yml
├── docs
├── API.md
└── VLAN_ARP_SCAN.md
├── docker-compose-auth.yml
├── CHANGELOG.md
└── README.md
/.version:
--------------------------------------------------------------------------------
1 | backend/internal/web/public/version
--------------------------------------------------------------------------------
/backend/internal/web/public/version:
--------------------------------------------------------------------------------
1 | VERSION=2.1.4
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore local data
2 | data/
3 | tmp/
4 |
5 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
| . | ");function B(){let l=[];l.push(...$);const o=localStorage.getItem("histShow");return n(+o),(c()===0||isNaN(c()))&&n(200),N(()=>{S()&&(l=[],l.push(...$),console.log("Upd on Filter"),E(!1))}),(()=>{var e=A(),r=e.firstChild,g=r.nextSibling,w=g.firstChild,y=w.firstChild;return t(r,i(M,{}),null),t(r,i(q,{name:"histShow"}),null),t(y,i(P,{get when(){return!S()},get children(){return i(U,{each:l,children:(a,x)=>(()=>{var f=D(),d=f.firstChild,C=d.firstChild,u=d.nextSibling,h=u.firstChild,F=h.nextSibling,b=F.nextSibling,H=u.nextSibling;return t(d,()=>x()+1,C),t(h,()=>a.Name),t(b,()=>a.IP),t(H,i(j,{get mac(){return a.Mac},date:""})),O(s=>{var p="/host/"+a.ID,v="http://"+a.IP;return p!==s.e&&_(h,"href",s.e=p),v!==s.t&&_(b,"href",s.t=v),s},{e:void 0,t:void 0}),f})()})}})),e})()}export{B as default};
2 |
--------------------------------------------------------------------------------
/docs/API.md:
--------------------------------------------------------------------------------
1 | ## API
2 | ```http
3 | GET /api/all
4 | ```
5 | Returns all hosts in `json`.
6 |
7 |
8 | ```http
9 | GET /api/history
10 | ```
11 | Returns all History. Not recommended, the output can be a lot.
12 |
13 | ```http
14 | GET /api/history/:mac/:date
15 | ```
16 | Returns only history of a device with this `mac` filtered by `date`. `date` format can be anything from `2` to `2025-07` to `2025-07-26`.
17 |
18 | ```http
19 | GET /api/history/:mac?num=20
20 | ```
21 | Returns only last 20 lines of history of a device with this `mac`.
22 |
23 |
24 | ```http
25 | GET /api/host/:id
26 | ```
27 | Returns host with this `id` in `json`.
28 |
29 |
30 | ```http
31 | GET /api/port/:addr/:port
32 | ```
33 | Gets state of one `port` of `addr`. Returns `true` if port is open or `false` otherwise.
34 |
35 | Request example36 | 37 | ```bash 38 | curl http://0.0.0.0:8840/api/port/192.168.2.2/8844 39 | ``` 40 |41 | 42 | 43 | ```http 44 | GET /api/edit/:id/:name/*known 45 | ``` 46 | Edit host with ID `id`. Can change `name`. `known` is optional, when set to `toggle` will change Known state. 47 | 48 | 49 | ```http 50 | GET /api/host/del/:id 51 | ``` 52 | Remove host with ID `id`. 53 | 54 | 55 | ```http 56 | GET /api/notify_test 57 | ``` 58 | Send test notification. 59 | 60 | 61 | ```http 62 | GET /api/status/*iface 63 | ``` 64 | Show status (Total number of hosts, online/offline, known/unknown). The `iface` parameter is optional and shows status for one interface only. For all interfaces just call `/api/status/`. -------------------------------------------------------------------------------- /frontend/src/components/Body/TableHead.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, For } from "solid-js"; 2 | import { Host } from "../../functions/exports"; 3 | import { sortByAnyField } from "../../functions/sort"; 4 | 5 | function TableHead() { 6 | 7 | const [sortField, setSortField] = createSignal
30 | | {key} |
38 | }
40 | |
29 |
47 | )
48 | }
49 |
50 | export default CardHead
51 |
--------------------------------------------------------------------------------
/backend/internal/influx/influx.go:
--------------------------------------------------------------------------------
1 | package influx
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "fmt"
7 | "log/slog"
8 | "strings"
9 |
10 | "github.com/influxdata/influxdb-client-go/v2"
11 |
12 | "github.com/aceberg/WatchYourLAN/internal/check"
13 | "github.com/aceberg/WatchYourLAN/internal/models"
14 | )
15 |
16 | // Add - write data to InfluxDB2
17 | func Add(appConfig models.Conf, oneHist models.Host) {
18 | var ctx context.Context
19 |
20 | client := influxdb2.NewClientWithOptions(appConfig.InfluxAddr, appConfig.InfluxToken,
21 | influxdb2.DefaultOptions().
22 | SetUseGZip(true).
23 | SetTLSConfig(&tls.Config{
24 | InsecureSkipVerify: appConfig.InfluxSkipTLS,
25 | }))
26 |
27 | ctx = context.Background()
28 | ping, err := client.Ping(ctx)
29 | if ping {
30 | writeAPI := client.WriteAPIBlocking(appConfig.InfluxOrg, appConfig.InfluxBucket)
31 |
32 | // Escape special characters in strings
33 | oneHist.Name = strings.ReplaceAll(oneHist.Name, " ", "\\ ")
34 | oneHist.Name = strings.ReplaceAll(oneHist.Name, ",", "\\,")
35 | oneHist.Name = strings.ReplaceAll(oneHist.Name, "=", "\\=")
36 | if oneHist.Name == "" {
37 | oneHist.Name = "unknown"
38 | }
39 |
40 | line := fmt.Sprintf("WatchYourLAN,IP=%s,iface=%s,name=%s,mac=%s,known=%d state=%d", oneHist.IP, oneHist.Iface, oneHist.Name, oneHist.Mac, oneHist.Known, oneHist.Now)
41 | // slog.Debug("Writing to InfluxDB", "line", line)
42 |
43 | err = writeAPI.WriteRecord(context.Background(), line)
44 | check.IfError(err)
45 | } else {
46 | slog.Error("Can't connect to InfluxDB server")
47 | check.IfError(err)
48 | }
49 |
50 | client.Close()
51 | }
52 |
--------------------------------------------------------------------------------
/backend/internal/api/api-network.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "log/slog"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/linde12/gowol"
9 |
10 | "github.com/aceberg/WatchYourLAN/internal/check"
11 | "github.com/aceberg/WatchYourLAN/internal/portscan"
12 | )
13 |
14 | // getPortState godoc
15 | // @Summary Check port state
16 | // @Description Check whether a given TCP port on an address is open or closed
17 | // @Tags network
18 | // @Produce json
19 | // @Param addr path string true "IP address or hostname"
20 | // @Param port path string true "Port number"
21 | // @Success 200 {boolean} bool "true if open, false if closed"
22 | // @Router /port/{addr}/{port} [get]
23 | func getPortState(c *gin.Context) {
24 | addr := c.Param("addr")
25 | port := c.Param("port")
26 | state := portscan.IsOpen(addr, port)
27 | c.IndentedJSON(http.StatusOK, state)
28 | }
29 |
30 | // sendWOL godoc
31 | // @Summary Send Wake-on-LAN packet
32 | // @Description Send a magic packet to wake up a host by its MAC address
33 | // @Tags network
34 | // @Produce json
35 | // @Param mac path string true "MAC address of the host"
36 | // @Success 200 {boolean} bool "true if sent successfully"
37 | // @Router /wol/{mac} [get]
38 | func sendWOL(c *gin.Context) {
39 |
40 | mac := c.Param("mac")
41 |
42 | packet, err := gowol.NewMagicPacket(mac)
43 |
44 | if !check.IfError(err) {
45 | err = packet.Send("255.255.255.255")
46 |
47 | slog.Info("Wake-on-LAN: " + mac)
48 | }
49 |
50 | c.IndentedJSON(http.StatusOK, !check.IfError(err))
51 | }
52 |
--------------------------------------------------------------------------------
/backend/internal/api/routes.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 |
6 | swaggerFiles "github.com/swaggo/files"
7 | ginSwagger "github.com/swaggo/gin-swagger"
8 | )
9 |
10 | // Routes - start API routes
11 | func Routes(router *gin.Engine) {
12 |
13 | r0 := router.Group("/api")
14 | {
15 | r0.GET("/all", getAllHosts) // api-hosts.go
16 | r0.GET("/edit/:id/:name/*known", editHost) // api-hosts.go
17 | r0.GET("/host/:id", getHost) // api-hosts.go
18 | r0.GET("/host/del/:id", delHost) // api-hosts.go
19 | r0.GET("/host/add/:mac", addHost) // api-hosts.go
20 |
21 | r0.GET("/config", getConfig) // api-system.go
22 | r0.GET("/notify_test", notifyTest) // api-system.go
23 | r0.GET("/status/*iface", getStatus) // api-system.go
24 | r0.GET("/version", getVersion) // api-system.go
25 | r0.GET("/rescan", triggerRescan) // api-system.go
26 |
27 | r0.GET("/history", getHistory) // api-history.go
28 | r0.GET("/history/:mac", getHistoryByMAC) // api-history.go
29 | r0.GET("/history/:mac/:date", getHistoryByDate) // api-history.go
30 |
31 | r0.GET("/port/:addr/:port", getPortState) // api-network.go
32 | r0.GET("/wol/:mac", sendWOL) // api-network.go
33 |
34 | r0.POST("/config/", saveConfigHandler) // config.go
35 | r0.POST("/config_settings/", saveSettingsHandler) // config.go
36 | r0.POST("/config_influx/", saveInfluxHandler) // config.go
37 | r0.POST("/config_prometheus/", savePrometheusHandler) // config.go
38 | }
39 |
40 | router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
41 | }
42 |
--------------------------------------------------------------------------------
/backend/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | project_name: watchyourlan
3 | builds:
4 | - main: ./cmd/WatchYourLAN/
5 | binary: watchyourlan
6 | id: default
7 | env: [CGO_ENABLED=0]
8 | goos:
9 | - linux
10 | goarch:
11 | - 386
12 | - amd64
13 | - arm
14 | - arm64
15 | goarm:
16 | - "5"
17 | - "6"
18 | - "7"
19 |
20 | nfpms:
21 | - id: systemd
22 | formats:
23 | - deb
24 | - rpm
25 | maintainer: aceberg
30 |
34 |
31 |
33 |
35 |
46 |
36 |
45 |
27 |
54 | )
55 | }
56 |
57 | export default History
58 |
--------------------------------------------------------------------------------
/backend/internal/web/webgui.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "embed"
5 | "html/template"
6 | "log/slog"
7 | "net/http"
8 |
9 | "github.com/aceberg/WatchYourLAN/internal/api"
10 | "github.com/aceberg/WatchYourLAN/internal/check"
11 | "github.com/aceberg/WatchYourLAN/internal/conf"
12 | "github.com/aceberg/WatchYourLAN/internal/prometheus"
13 | "github.com/gin-gonic/gin"
14 | )
15 |
16 | // templFS - html templates
17 | //
18 | //go:embed templates/*
19 | var templFS embed.FS
20 |
21 | // pubFS - public folder
22 | //
23 | //go:embed public/*
24 | var pubFS embed.FS
25 |
26 | // Gui - start web server
27 | func Gui() {
28 | const (
29 | colorCyan = "\033[36m"
30 | colorReset = "\033[0m"
31 | )
32 |
33 | file, err := pubFS.ReadFile("public/version")
34 | check.IfError(err)
35 | conf.AppConfig.Version = string(file)[8:]
36 |
37 | address := conf.AppConfig.Host + ":" + conf.AppConfig.Port
38 |
39 | slog.Info(colorCyan + "\n=================================== " +
40 | "\n WatchYourLAN Version: " + conf.AppConfig.Version +
41 | "\n Config dir: " + conf.AppConfig.DirPath +
42 | "\n Default DB: " + conf.AppConfig.UseDB +
43 | "\n Log level: " + conf.AppConfig.LogLevel +
44 | "\n Web GUI: http://" + address +
45 | "\n=================================== " + colorReset)
46 |
47 | gin.SetMode(gin.ReleaseMode)
48 | router := gin.New()
49 | router.Use(gin.Recovery())
50 |
51 | templ := template.Must(template.New("").ParseFS(templFS, "templates/*"))
52 | router.SetHTMLTemplate(templ) // templates
53 |
54 | router.StaticFS("/fs/", http.FS(pubFS)) // public
55 |
56 | router.GET("/", indexHandler) // index.go
57 | router.GET("/config", indexHandler) // index.go
58 | router.GET("/history", indexHandler) // index.go
59 | router.GET("/host/*any", indexHandler) // index.go
60 | router.GET("/metrics", prometheus.Handler())
61 |
62 | api.Routes(router)
63 |
64 | err = router.Run(address)
65 | check.IfError(err)
66 | }
67 |
--------------------------------------------------------------------------------
/.github/workflows/main-docker-all.yml:
--------------------------------------------------------------------------------
1 | name: Main-Docker
2 |
3 | on:
4 | workflow_dispatch:
5 | # push:
6 | # branches: [ "main" ]
7 | # paths:
8 | # - 'Dockerfile'
9 | # - 'src/**'
10 |
11 | env:
12 | IMAGE_NAME: watchyourlan
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-22.04
17 |
18 | steps:
19 | - name: Checkout repository
20 | uses: actions/checkout@v4
21 |
22 | - name: Get version tag from env file
23 | uses: c-py/action-dotenv-to-setenv@v5
24 | with:
25 | env-file: .version
26 |
27 | - name: Set up QEMU
28 | uses: docker/setup-qemu-action@v3
29 |
30 | - name: Set up Docker Buildx
31 | id: buildx
32 | uses: docker/setup-buildx-action@v3
33 |
34 | - name: Login to GHCR
35 | uses: docker/login-action@v3
36 | with:
37 | registry: ghcr.io
38 | username: ${{ github.actor }}
39 | password: ${{ secrets.GITHUB_TOKEN }}
40 |
41 | - name: Login to Docker Hub
42 | uses: docker/login-action@v3
43 | with:
44 | username: ${{ secrets.DOCKER_USERNAME }}
45 | password: ${{ secrets.DOCKER_PASSWORD }}
46 |
47 | - name: Build and push
48 | uses: docker/build-push-action@v6
49 | with:
50 | context: .
51 | platforms: linux/amd64,linux/arm/v7,linux/arm64
52 | push: true
53 | tags: |
54 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:v2
55 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
56 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
57 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:v2
58 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
59 | ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
60 | cache-from: type=gha
61 | cache-to: type=gha,mode=max
62 |
--------------------------------------------------------------------------------
/docker-compose-auth.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | wyl:
4 | image: aceberg/watchyourlan
5 | network_mode: "host"
6 | restart: unless-stopped
7 | volumes:
8 | - ~/.dockerdata/wyl:/data/WatchYourLAN
9 | environment:
10 | TZ: Asia/Novosibirsk # required: needs your TZ for correct time
11 | IFACES: "enp4s0 wlxf4ec3892dd51" # required: 1 or more interface
12 | HOST: "0.0.0.0" # optional, default: 0.0.0.0
13 | PORT: "8840" # optional, default: 8840
14 | TIMEOUT: "120" # optional, time in seconds, default: 120
15 | SHOUTRRR_URL: "" # optional, set url to notify
16 | THEME: "sand" # optional
17 | COLOR: "dark" # optional
18 |
19 | # WARNING! WYL needs 'host' network mode to work. So, WYL port will be exposed in this setup. You need to limit access to it with firewall or other measures
20 |
21 | forauth:
22 | image: aceberg/forauth
23 | restart: unless-stopped
24 | ports:
25 | - 8800:8800 # Proxy port
26 | - 8801:8801 # Config port
27 | volumes:
28 | - ~/.dockerdata/forauth:/data/ForAuth
29 | environment:
30 | TZ: Asia/Novosibirsk # required: needs your TZ for correct time
31 | FA_TARGET: "YOUR_IP:8840" # optional: path to wyl host:port
32 | FA_AUTH: "true" # optional: true - enabled, default: false
33 | FA_AUTH_EXPIRE: 7d # optional: expiration time, default: 7d
34 | FA_AUTH_PASSWORD: "$$2a$$10$$wGLUHXh2cRN1257uGg1s5eZvYgnjw8wB9vAcfcHqqqrxm5hvBqAzK"
35 | # WARNING! If password is set as environment variable, every '$' character must be escaped with another '$', like this '$$'
36 | # optional: password encrypted with bcrypt, how-to: https://github.com/aceberg/ForAuth/blob/main/docs/BCRYPT.md (In this example FA_AUTH_PASSWORD=pw)
37 | FA_AUTH_USER: user # optional: username
38 |
--------------------------------------------------------------------------------
/frontend/src/components/Filter.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, For } from "solid-js";
2 | import { Host, ifaces, setHistUpdOnFilter } from "../functions/exports";
3 | import { filterFunc } from "../functions/filter";
4 |
5 |
6 | function Filter() {
7 | type FilterEvent = Event & {
8 | currentTarget: HTMLSelectElement;
9 | target: HTMLSelectElement;
10 | };
11 |
12 | const [selectValue, setSelectValue] = createSignal("");
13 |
14 | const handleFilter = (field: keyof Host, event: FilterEvent) => {
15 | const value = event.target ? event.target.value : 0;
16 | filterFunc(field, value);
17 | setHistUpdOnFilter(true);
18 | };
19 |
20 | const handleReset = () => {
21 | filterFunc("ID", 0);
22 | setSelectValue("something");
23 | setSelectValue("");
24 | setHistUpdOnFilter(true);
25 | };
26 |
27 | return (
28 |
28 |
31 |
32 |
53 |
29 |
49 | )
50 | }
51 |
52 | export default Filter
53 |
--------------------------------------------------------------------------------
/backend/internal/gdb/start.go:
--------------------------------------------------------------------------------
1 | package gdb
2 |
3 | import (
4 | "log"
5 | "log/slog"
6 | "os"
7 | "time"
8 |
9 | sqlite "github.com/aceberg/gorm-sqlite"
10 |
11 | "gorm.io/driver/postgres"
12 | "gorm.io/gorm"
13 | "gorm.io/gorm/logger"
14 | "gorm.io/gorm/schema"
15 |
16 | "github.com/aceberg/WatchYourLAN/internal/check"
17 | "github.com/aceberg/WatchYourLAN/internal/conf"
18 | "github.com/aceberg/WatchYourLAN/internal/models"
19 | )
20 |
21 | var db *gorm.DB
22 | var gormConf *gorm.Config
23 |
24 | // Start working with DB
25 | func Start() {
26 | var tab *gorm.DB
27 | var err error
28 |
29 | newLogger := logger.New(
30 | log.New(os.Stdout, "\r\n", log.LstdFlags),
31 | logger.Config{
32 | SlowThreshold: 5 * time.Second,
33 | LogLevel: logger.Warn,
34 | IgnoreRecordNotFoundError: true,
35 | Colorful: true,
36 | },
37 | )
38 | gormConf = &gorm.Config{
39 | Logger: newLogger,
40 | NamingStrategy: schema.NamingStrategy{
41 | NoLowerCase: true,
42 | // So upper case Columns could work in both PostgreSQL and SQLite
43 | },
44 | }
45 |
46 | Connect()
47 |
48 | // Migrate the schema
49 | tab = db.Table("now")
50 | err = tab.AutoMigrate(&models.Host{})
51 | check.IfError(err)
52 |
53 | tab = db.Table("history")
54 | err = tab.AutoMigrate(&models.Host{})
55 | check.IfError(err)
56 | }
57 |
58 | // Connect - choose DB and connect
59 | func Connect() {
60 | var err error
61 | var pgFail bool
62 |
63 | if conf.AppConfig.UseDB == "postgres" {
64 | db, err = gorm.Open(postgres.Open(conf.AppConfig.PGConnect), gormConf)
65 |
66 | if err != nil {
67 | pgFail = true
68 |
69 | slog.Error("PostgreSQL connection error:", "err", err)
70 | slog.Warn("Falling back to SQLite")
71 | } else {
72 | slog.Info("Connected to DB: PostgreSQL")
73 | }
74 | }
75 |
76 | if pgFail || conf.AppConfig.UseDB != "postgres" {
77 |
78 | db, err = gorm.Open(sqlite.Open(conf.AppConfig.DBPath), gormConf)
79 |
80 | if !check.IfError(err) {
81 | slog.Info("Connected to DB: SQLite")
82 | db.Exec("PRAGMA journal_mode = wal;")
83 | db.Exec("PRAGMA busy_timeout = 5000;")
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/src/functions/api.ts:
--------------------------------------------------------------------------------
1 | export const apiPath = 'http://0.0.0.0:8840';
2 |
3 | export const apiGetAllHosts = async () => {
4 | const url = apiPath+'/api/all';
5 | const hosts = await (await fetch(url)).json();
6 |
7 | return hosts;
8 | };
9 |
10 | export const apiGetConfig = async () => {
11 |
12 | const url = apiPath+'/api/config';
13 | const res = await (await fetch(url)).json();
14 |
15 | return res;
16 | };
17 |
18 | export const apiGetVersion = async () => {
19 |
20 | const url = apiPath+'/api/version';
21 | const res = await (await fetch(url)).json();
22 |
23 | return res;
24 | };
25 |
26 | export const apiTestNotify = async () => {
27 |
28 | const url = apiPath+'/api/notify_test';
29 | await fetch(url);
30 | };
31 |
32 | export const apiEditHost = async (id:number, name:string, known:string) => {
33 |
34 | const url = apiPath+'/api/edit/'+id+'/'+name+'/'+known;
35 | const res = await (await fetch(url)).json();
36 |
37 | return res;
38 | };
39 |
40 | export const apiGetHost = async (id:string) => {
41 |
42 | const url = apiPath+'/api/host/'+id;
43 | const res = await (await fetch(url)).json();
44 |
45 | return res;
46 | };
47 |
48 | export const apiDelHost = async (id:number) => {
49 |
50 | const url = apiPath+'/api/host/del/'+id;
51 | const res = await (await fetch(url)).json();
52 |
53 | return res;
54 | };
55 |
56 | export const apiPortScan = async (ip:string, port:number) => {
57 |
58 | const url = apiPath+'/api/port/'+ip+'/'+port;
59 | const res = await (await fetch(url)).json();
60 |
61 | return res;
62 | };
63 |
64 | export const apiGetHistory = async (mac:string) => {
65 | const url = apiPath+'/api/history/'+mac+'/?num=210';
66 | const hosts = await (await fetch(url)).json();
67 |
68 | return hosts;
69 | };
70 |
71 | export const apiGetHistoryByDate = async (mac:string, date: string) => {
72 | const url = apiPath+'/api/history/'+mac+'/'+date;
73 | const hosts = await (await fetch(url)).json();
74 |
75 | return hosts;
76 | };
77 |
78 | export const apiWOL = async (mac:string) => {
79 |
80 | const url = apiPath+'/api/wol/'+mac;
81 | const res = await (await fetch(url)).json();
82 |
83 | return res;
84 | };
--------------------------------------------------------------------------------
/backend/internal/api/api-history.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 |
9 | "github.com/aceberg/WatchYourLAN/internal/gdb"
10 | )
11 |
12 | // getHistory godoc
13 | // @Summary Get full history
14 | // @Description Retrieve the complete history of all hosts. Not recommended, the output can be a lot
15 | // @Tags history
16 | // @Produce json
17 | // @Success 200 {array} models.Host
18 | // @Router /history [get]
19 | func getHistory(c *gin.Context) {
20 | hosts, _ := gdb.Select("history")
21 | c.IndentedJSON(http.StatusOK, hosts)
22 | }
23 |
24 | // getHistoryByMAC godoc
25 | // @Summary Get history by MAC
26 | // @Description Retrieve the latest history entries for a specific host by MAC address
27 | // @Tags history
28 | // @Produce json
29 | // @Param mac path string true "MAC address of the host"
30 | // @Param num query int true "Number of history entries to return"
31 | // @Success 200 {array} models.Host
32 | // @Router /history/{mac} [get]
33 | func getHistoryByMAC(c *gin.Context) {
34 | mac := c.Param("mac")
35 | numStr := c.Query("num")
36 | num, _ := strconv.Atoi(numStr)
37 | hosts := gdb.SelectLatest(mac, num)
38 | c.IndentedJSON(http.StatusOK, hosts)
39 | }
40 |
41 | // getHistoryByDate godoc
42 | // @Summary Get history by date
43 | // @Description Retrieve history for a specific host on a given date
44 | // @Description The date format is flexible and can be:
45 | // @Description - Year only: `2025`
46 | // @Description - Year + month: `2025-09`
47 | // @Description - Full date: `2025-09-06`
48 | // @Description - Full timestamp: `2025-09-06 00:58:26`
49 | // @Tags history
50 | // @Produce json
51 | // @Param mac path string true "MAC address of the host"
52 | // @Param date path string true "Date filter (supports YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DD HH:mm:ss)"
53 | // @Success 200 {array} models.Host
54 | // @Router /history/{mac}/{date} [get]
55 | func getHistoryByDate(c *gin.Context) {
56 | mac := c.Param("mac")
57 | date := c.Param("date")
58 | hosts := gdb.SelectByDate(mac, date)
59 | c.IndentedJSON(http.StatusOK, hosts)
60 | }
61 |
--------------------------------------------------------------------------------
/frontend/src/functions/exports.ts:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { createStore } from "solid-js/store";
3 |
4 | export interface Host {
5 | ID: number;
6 | Name: string;
7 | DNS: string;
8 | Iface: string;
9 | IP: string;
10 | Mac: string;
11 | Hw: string;
12 | Date: string;
13 | Known: number;
14 | Now: number;
15 | };
16 |
17 | export interface Conf {
18 | Host: string;
19 | Port: string;
20 | Theme: string;
21 | Color: string;
22 | DirPath: string;
23 | Timeout: number;
24 | NodePath: string;
25 | LogLevel: string;
26 | Ifaces: string;
27 | ArpArgs: string;
28 | ArpStrs: string[];
29 | TrimHist: number;
30 | ShoutURL: string;
31 | UseDB: string;
32 | PGConnect: string;
33 | // InfluxDB
34 | InfluxEnable: boolean;
35 | InfluxAddr: string;
36 | InfluxToken: string;
37 | InfluxOrg: string;
38 | InfluxBucket: string;
39 | InfluxSkipTLS: boolean;
40 | // Prometheus
41 | PrometheusEnable: boolean;
42 | };
43 |
44 | export const emptyHost:Host = {
45 | ID: 0,
46 | Name: "",
47 | DNS: "",
48 | Iface: "",
49 | IP: "",
50 | Mac: "",
51 | Hw: "",
52 | Date: "",
53 | Known: 0,
54 | Now: 0,
55 | };
56 |
57 | export const emptyConf:Conf = {
58 | Host: "",
59 | Port: "",
60 | Theme: "",
61 | Color: "",
62 | DirPath: "",
63 | Timeout: 120,
64 | NodePath: "",
65 | LogLevel: "",
66 | Ifaces: "",
67 | ArpArgs: "",
68 | ArpStrs: [],
69 | TrimHist: 48,
70 | ShoutURL: "",
71 | UseDB: "",
72 | PGConnect: "",
73 | InfluxEnable: false,
74 | InfluxAddr: "",
75 | InfluxToken: "",
76 | InfluxOrg: "",
77 | InfluxBucket: "",
78 | InfluxSkipTLS: false,
79 | PrometheusEnable: false,
80 | };
81 |
82 | export const [allHosts, setAllHosts] = createStore
30 |
36 |
41 |
46 |
47 |
48 |
50 |
73 | )
74 | }
75 |
76 | export default Ping
--------------------------------------------------------------------------------
/backend/internal/conf/read.go:
--------------------------------------------------------------------------------
1 | package conf
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/spf13/viper"
7 |
8 | "github.com/aceberg/WatchYourLAN/internal/check"
9 | "github.com/aceberg/WatchYourLAN/internal/models"
10 | )
11 |
12 | func read(path string) (config models.Conf) {
13 |
14 | viper.SetDefault("HOST", "0.0.0.0")
15 | viper.SetDefault("PORT", "8840")
16 | viper.SetDefault("THEME", "sand")
17 | viper.SetDefault("COLOR", "dark")
18 | viper.SetDefault("NODEPATH", "")
19 | viper.SetDefault("LOG_LEVEL", "info")
20 | viper.SetDefault("ARP_ARGS", "")
21 | viper.SetDefault("ARP_STRS_JOINED", "")
22 | viper.SetDefault("IFACES", "")
23 | viper.SetDefault("TIMEOUT", 120)
24 | viper.SetDefault("TRIM_HIST", 48)
25 | viper.SetDefault("SHOUTRRR_URL", "")
26 |
27 | viper.SetDefault("USE_DB", "sqlite")
28 | viper.SetDefault("PG_CONNECT", "")
29 |
30 | viper.SetDefault("INFLUX_ENABLE", false)
31 |
32 | viper.SetDefault("PROMETHEUS_ENABLE", false)
33 |
34 | viper.SetConfigFile(path)
35 | viper.SetConfigType("yaml")
36 | err := viper.ReadInConfig()
37 | check.IfError(err)
38 |
39 | viper.AutomaticEnv() // Get ENVIRONMENT variables
40 |
41 | config.Host = viper.Get("HOST").(string)
42 | config.Port = viper.Get("PORT").(string)
43 | config.Theme = viper.Get("THEME").(string)
44 | config.Color = viper.Get("COLOR").(string)
45 | config.NodePath = viper.Get("NODEPATH").(string)
46 | config.LogLevel = viper.Get("LOG_LEVEL").(string)
47 | config.ArpArgs = viper.Get("ARP_ARGS").(string)
48 | config.ArpStrs = viper.GetStringSlice("ARP_STRS")
49 | config.Ifaces = viper.Get("IFACES").(string)
50 | config.Timeout = viper.GetInt("TIMEOUT")
51 | config.TrimHist = viper.GetInt("TRIM_HIST")
52 | config.ShoutURL = viper.Get("SHOUTRRR_URL").(string)
53 |
54 | config.UseDB = viper.Get("USE_DB").(string)
55 | config.PGConnect = viper.Get("PG_CONNECT").(string)
56 |
57 | config.InfluxEnable = viper.GetBool("INFLUX_ENABLE")
58 | config.InfluxSkipTLS = viper.GetBool("INFLUX_SKIP_TLS")
59 | config.InfluxAddr, _ = viper.Get("INFLUX_ADDR").(string)
60 | config.InfluxToken, _ = viper.Get("INFLUX_TOKEN").(string)
61 | config.InfluxOrg, _ = viper.Get("INFLUX_ORG").(string)
62 | config.InfluxBucket, _ = viper.Get("INFLUX_BUCKET").(string)
63 |
64 | config.PrometheusEnable = viper.GetBool("PROMETHEUS_ENABLE")
65 |
66 | joined := viper.Get("ARP_STRS_JOINED").(string)
67 | // slog.Info("ARP_STRS_JOINED: " + joined)
68 |
69 | if joined != "" {
70 | config.ArpStrs = strings.Split(joined, ",")
71 | }
72 |
73 | return config
74 | }
75 |
--------------------------------------------------------------------------------
/backend/internal/api/config.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 |
9 | "github.com/aceberg/WatchYourLAN/internal/conf"
10 | "github.com/aceberg/WatchYourLAN/internal/gdb"
11 | "github.com/aceberg/WatchYourLAN/internal/routines"
12 | )
13 |
14 | func saveConfigHandler(c *gin.Context) {
15 |
16 | conf.AppConfig.Host = c.PostForm("host")
17 | conf.AppConfig.Port = c.PostForm("port")
18 | conf.AppConfig.Theme = c.PostForm("theme")
19 | conf.AppConfig.Color = c.PostForm("color")
20 | conf.AppConfig.NodePath = c.PostForm("node")
21 | conf.AppConfig.ShoutURL = c.PostForm("shout")
22 |
23 | conf.Write(conf.AppConfig)
24 |
25 | c.Redirect(http.StatusFound, c.Request.Referer())
26 | }
27 |
28 | func saveSettingsHandler(c *gin.Context) {
29 |
30 | conf.AppConfig.LogLevel = c.PostForm("log")
31 | conf.AppConfig.ArpArgs = c.PostForm("arpargs")
32 | conf.AppConfig.Ifaces = c.PostForm("ifaces")
33 |
34 | useDB := c.PostForm("usedb")
35 | pgConnect := c.PostForm("pgconnect")
36 |
37 | if useDB != conf.AppConfig.UseDB || pgConnect != conf.AppConfig.PGConnect {
38 | conf.AppConfig.UseDB = c.PostForm("usedb")
39 | conf.AppConfig.PGConnect = c.PostForm("pgconnect")
40 | gdb.Connect()
41 | }
42 |
43 | timeout := c.PostForm("timeout")
44 | trimHist := c.PostForm("trim")
45 | conf.AppConfig.Timeout, _ = strconv.Atoi(timeout)
46 | conf.AppConfig.TrimHist, _ = strconv.Atoi(trimHist)
47 |
48 | arpStrs := c.PostFormArray("arpstrs")
49 | conf.AppConfig.ArpStrs = []string{}
50 | for _, s := range arpStrs {
51 | if s != "" {
52 | conf.AppConfig.ArpStrs = append(conf.AppConfig.ArpStrs, s)
53 | }
54 | }
55 |
56 | conf.Write(conf.AppConfig)
57 |
58 | routines.ScanRestart()
59 |
60 | c.Redirect(http.StatusFound, c.Request.Referer())
61 | }
62 |
63 | func saveInfluxHandler(c *gin.Context) {
64 |
65 | conf.AppConfig.InfluxAddr = c.PostForm("addr")
66 | conf.AppConfig.InfluxToken = c.PostForm("token")
67 | conf.AppConfig.InfluxOrg = c.PostForm("org")
68 | conf.AppConfig.InfluxBucket = c.PostForm("bucket")
69 |
70 | enable := c.PostForm("enable")
71 | skip := c.PostForm("skip")
72 | conf.AppConfig.InfluxEnable = enable == "on"
73 | conf.AppConfig.InfluxSkipTLS = skip == "on"
74 |
75 | conf.Write(conf.AppConfig)
76 |
77 | c.Redirect(http.StatusFound, c.Request.Referer())
78 | }
79 |
80 | func savePrometheusHandler(c *gin.Context) {
81 | enable := c.PostForm("enable")
82 |
83 | conf.AppConfig.PrometheusEnable = enable == "on"
84 |
85 | conf.Write(conf.AppConfig)
86 |
87 | c.Redirect(http.StatusFound, c.Request.Referer())
88 | }
89 |
--------------------------------------------------------------------------------
/frontend/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { appConfig, setAppConfig } from "../functions/exports";
3 | import { apiGetConfig } from "../functions/api";
4 |
5 | function Header() {
6 |
7 | const [themePath, setThemePath] = createSignal('');
8 | const [iconsPath, setIconsPath] = createSignal('');
9 |
10 | const setCurrentTheme = async () => {
11 | setAppConfig(await apiGetConfig());
12 |
13 | const theme = appConfig().Theme?appConfig().Theme:"sand";
14 | const color = appConfig().Color?appConfig().Color:"dark";
15 |
16 | if (appConfig().NodePath == '') {
17 | setThemePath("https://cdn.jsdelivr.net/npm/aceberg-bootswatch-fork@v5.3.3-2/dist/"+theme+"/bootstrap.min.css");
18 | setIconsPath("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css");
19 | } else {
20 | setThemePath(appConfig().NodePath+"/node_modules/bootswatch/dist/"+theme+"/bootstrap.min.css");
21 | setIconsPath(appConfig().NodePath+"/node_modules/bootstrap-icons/font/bootstrap-icons.css");
22 | }
23 |
24 | document.documentElement.setAttribute("data-bs-theme", color);
25 | color === "dark"
26 | ? document.documentElement.style.setProperty('--transparent-light', '#ffffff15')
27 | : document.documentElement.style.setProperty('--transparent-light', '#00000015');
28 | }
29 | setCurrentTheme();
30 |
31 | return (
32 | <>
33 | {/* icons */}
34 | {/* theme */}
35 |
58 | >
59 | )
60 | };
61 |
62 | export default Header
63 |
--------------------------------------------------------------------------------
/frontend/src/components/Config/Influx.tsx:
--------------------------------------------------------------------------------
1 | import { apiPath } from "../../functions/api"
2 | import { appConfig } from "../../functions/exports"
3 |
4 | function Influx() {
5 |
6 | return (
7 | Port Scan
51 |
52 |
59 | {curPort() != ""
60 | ?
72 |
61 |
62 |
64 | : <>>
65 | }
66 | Scanning port: {curPort()}
63 |
67 |
71 |
8 |
58 | )
59 | }
60 |
61 | export default Influx
--------------------------------------------------------------------------------
/frontend/src/components/Body/TableRow.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, Show } from "solid-js";
2 | import { editNames, selectedIDs, setSelectedIDs } from "../../functions/exports";
3 | import { apiEditHost } from "../../functions/api";
4 |
5 | import { debounce } from "@solid-primitives/scheduled";
6 |
7 | function TableRow(_props: any) {
8 |
9 | const [name, setName] = createSignal(_props.host.Name);
10 |
11 | let now = ;
12 | if (_props.host.Now == 1) {
13 | now = ;
14 | };
15 |
16 | let known:boolean;
17 | _props.host.Known === 1 ? known = true : known = false;
18 |
19 | const debouncedApi = debounce(async (val: string) => {
20 | await apiEditHost(_props.host.ID, val, "");
21 | }, 300);
22 |
23 | const handleInput = async (n: string) => {
24 | setName(n);
25 | debouncedApi(n);
26 | };
27 | const handleToggle = async () => {
28 | await apiEditHost(_props.host.ID, name(), "toggle");
29 | };
30 |
31 | const handleCheck = (checked: boolean) => {
32 | const id = _props.host.ID;
33 | setSelectedIDs(prev => {
34 | if (checked) {
35 | return prev.includes(id) ? prev : [...prev, id];
36 | } else {
37 | return prev.filter(item => item !== id);
38 | }
39 | });
40 | };
41 |
42 | return (
43 | InfluxDB2 config
9 |
10 |
56 |
57 | {_props.index}. |
45 |
46 | |
54 | {_props.host.Iface} |
55 | {_props.host.IP} |
56 | {_props.host.Mac} |
57 | {_props.host.Hw.slice(0,12)+".."} |
58 | {_props.host.Date} |
59 |
60 | |
65 |
61 |
63 |
64 | {now} |
66 |
67 | |
82 |
17 |
61 | )
62 | }
63 |
64 | export default About
--------------------------------------------------------------------------------
/backend/internal/api/api-system.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 |
8 | "github.com/aceberg/WatchYourLAN/internal/conf"
9 | "github.com/aceberg/WatchYourLAN/internal/gdb"
10 | "github.com/aceberg/WatchYourLAN/internal/models"
11 | "github.com/aceberg/WatchYourLAN/internal/notify"
12 | "github.com/aceberg/WatchYourLAN/internal/routines"
13 | )
14 |
15 | // getVersion godoc
16 | // @Summary Get application version
17 | // @Description Returns the current running version of the application
18 | // @Tags system
19 | // @Produce json
20 | // @Success 200 {string} string
21 | // @Router /version [get]
22 | func getVersion(c *gin.Context) {
23 | c.IndentedJSON(http.StatusOK, conf.AppConfig.Version)
24 | }
25 |
26 | // triggerRescan godoc
27 | // @Summary Rescan all interfaces now
28 | // @Description Manually trigger rescan
29 | // @Tags system
30 | // @Produce json
31 | // @Success 200 {string} string "OK"
32 | // @Router /rescan [get]
33 | func triggerRescan(c *gin.Context) {
34 | routines.ScanRestart()
35 | c.Status(http.StatusOK)
36 | }
37 |
38 | // getConfig godoc
39 | // @Summary Get application configuration
40 | // @Description Returns the current configuration used by the app
41 | // @Tags system
42 | // @Produce json
43 | // @Success 200 {object} models.Conf
44 | // @Router /config [get]
45 | func getConfig(c *gin.Context) {
46 | c.IndentedJSON(http.StatusOK, conf.AppConfig)
47 | }
48 |
49 | // notifyTest godoc
50 | // @Summary Send test notification
51 | // @Description Trigger a test notification to verify notification settings
52 | // @Tags system
53 | // @Produce json
54 | // @Success 200 {string} string "OK"
55 | // @Router /notify_test [get]
56 | func notifyTest(c *gin.Context) {
57 | notify.Test()
58 | c.Status(http.StatusOK)
59 | }
60 |
61 | // getStatus godoc
62 | // @Summary Get network status
63 | // @Description Retrieve summary statistics of hosts, optionally filtered by interface
64 | // @Tags system
65 | // @Produce json
66 | // @Param iface path string false "Interface name (omit for all interfaces)"
67 | // @Success 200 {object} models.Stat
68 | // @Router /status/{iface} [get]
69 | func getStatus(c *gin.Context) {
70 | var status models.Stat
71 | var searchHosts []models.Host
72 |
73 | allHosts, _ := gdb.Select("now")
74 |
75 | iface := c.Param("iface")
76 | iface = iface[1:]
77 |
78 | if iface != "" && iface != "undefined" {
79 | for _, host := range allHosts {
80 | if iface == host.Iface {
81 | searchHosts = append(searchHosts, host)
82 | }
83 | }
84 | } else {
85 | searchHosts = allHosts
86 | }
87 |
88 | for _, host := range searchHosts {
89 | status.Total = status.Total + 1
90 |
91 | if host.Known > 0 {
92 | status.Known = status.Known + 1
93 | } else {
94 | status.Unknown = status.Unknown + 1
95 | }
96 | if host.Now > 0 {
97 | status.Online = status.Online + 1
98 | } else {
99 | status.Offline = status.Offline + 1
100 | }
101 | }
102 |
103 | c.IndentedJSON(http.StatusOK, status)
104 | }
105 |
--------------------------------------------------------------------------------
/frontend/src/components/Config/Basic.tsx:
--------------------------------------------------------------------------------
1 | import { For, Show } from "solid-js";
2 | import { apiPath, apiTestNotify } from "../../functions/api"
3 | import { appConfig } from "../../functions/exports"
4 |
5 | function Basic() {
6 |
7 | const themes = ["cerulean", "cosmo", "cyborg", "darkly", "emerald", "flatly", "grass", "grayscale", "journal", "litera", "lumen", "lux", "materia", "minty", "morph", "ocean", "pulse", "quartz", "sand", "sandstone", "simplex", "sketchy", "slate", "solar", "spacelab", "superhero", "united", "vapor", "wood", "yeti", "zephyr"];
8 |
9 | const handleTestNotify = () => {
10 | apiTestNotify();
11 | };
12 |
13 | return (
14 |
18 | About ({version()})
19 |
20 |
21 |
60 |
15 |
80 | )
81 | }
82 |
83 | export default Basic
--------------------------------------------------------------------------------
/frontend/src/components/Config/Scan.tsx:
--------------------------------------------------------------------------------
1 | import { For, Show } from "solid-js"
2 | import { appConfig } from "../../functions/exports"
3 | import { apiPath } from "../../functions/api"
4 |
5 | function Scan() {
6 |
7 | return (
8 | Basic config
16 |
17 |
78 |
79 |
9 |
80 | )
81 | }
82 |
83 | export default Scan
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 | All notable changes to this project will be documented in this file.
3 |
4 | ## [v2.1.4] - 2025-09-10
5 | ### Added
6 | - Swagger API docs (`/swagger/index.html`)
7 | - Add host from API [#72](https://github.com/aceberg/WatchYourLAN/issues/72)
8 | - Trigger rescan from API or by pressing `Save` on `Config/Scan settings` [#74](https://github.com/aceberg/WatchYourLAN/issues/74)
9 | - Delete selected hosts [#195](https://github.com/aceberg/WatchYourLAN/issues/195)
10 | - Wake-on-LAN [#135](https://github.com/aceberg/WatchYourLAN/issues/135), [#196](https://github.com/aceberg/WatchYourLAN/issues/196)
11 |
12 | ## [v2.1.3] - 2025-07-26
13 | ### Fixed
14 | - Memory leak bug [#149](https://github.com/aceberg/WatchYourLAN/issues/149)
15 | - Duplicated devices bug [#187](https://github.com/aceberg/WatchYourLAN/issues/187) [#198](https://github.com/aceberg/WatchYourLAN/issues/198)
16 |
17 | ### Changed
18 | - **DEPRECATED:** `HIST_IN_DB` config option. Now history is always stored in `DB`
19 | - Upd to `go 1.24.5`
20 | - Moved `DB` handling to `GORM`
21 | - Moved to maintained `Shoutrrr`: [github.com/nicholas-fedor/shoutrrr](https://github.com/nicholas-fedor/shoutrrr) ([#197](https://github.com/aceberg/WatchYourLAN/issues/197))
22 |
23 | ## [v2.1.2] - 2025-03-30
24 | ### Fixed
25 | - Edit names bug
26 | - History page full rerenders replaced with only rerendering updated data
27 | - Select options reset
28 |
29 | ## [v2.1.1] - 2025-03-26
30 | ### Fixed
31 | - Filter bug in Chrome
32 |
33 | ## [v2.1.0] - 2025-03-25
34 | ### Added
35 | - Rewrited GUI in `SolidJS` and `TypeScript`
36 | - Prometheus integration [#181](https://github.com/aceberg/WatchYourLAN/pull/181)
37 | - Optimized Docker build [#180](https://github.com/aceberg/WatchYourLAN/pull/180)
38 |
39 | ### Fixed
40 | - Vite: file names
41 | - Node Path bug
42 |
43 | ## [v2.0.4] - 2024-10-21
44 | ### Added
45 | - Notification test [#147](https://github.com/aceberg/WatchYourLAN/issues/147)
46 | - API status [#148](https://github.com/aceberg/WatchYourLAN/issues/148)
47 |
48 | ### Fixed
49 | - [#101](https://github.com/aceberg/WatchYourLAN/issues/101)
50 | - The same problem for Theme, Color mode, Log level
51 | - Sort bug in Chrome [#140](https://github.com/aceberg/WatchYourLAN/issues/140)
52 |
53 | ## [v2.0.3] - 2024-09-17
54 | ### Fixed
55 | - `ARP_STRS_JOINED` should be empty in config file
56 | - Optimized History Trim
57 |
58 | ## [v2.0.2] - 2024-09-07
59 | ### Added
60 | - Remember Refresh setting in browser [#123](https://github.com/aceberg/WatchYourLAN/issues/123)
61 |
62 | ### Fixed
63 | - Error when `IFACES` are empty
64 | - Sticky sort bug fix
65 | - Bug [#124](https://github.com/aceberg/WatchYourLAN/issues/124)
66 | - Bug [#128](https://github.com/aceberg/WatchYourLAN/issues/128)
67 |
68 |
69 | ## [v2.0.1] - 2024-09-02
70 | ### Added
71 | - `Vlans` and `docker0` support [#47](https://github.com/aceberg/WatchYourLAN/issues/47). Thanks [thehijacker](https://github.com/thehijacker)!
72 | - Remember `sort` field
73 | - `InfluxDB` error handling
74 |
75 | ### Fixed
76 | - Bug [#103](https://github.com/aceberg/WatchYourLAN/issues/103)
77 | - Bug [#104](https://github.com/aceberg/WatchYourLAN/issues/104). Thanks [Steve Clement](https://github.com/SteveClement)!
78 |
79 | ## [v2.0.0] - 2024-08-30
80 | ### Added
81 | - API
82 | - Arguments for `arp-scan` option
83 | - `InfluxDB` export
84 | - `PostgreSQL` or `SQLite` DB options
85 | - Names from DNS
86 |
87 | ### Changed
88 | - Better UI with JS
89 | - Switched to `gin` web framework
90 | - Reworked DB schema and config variables
91 |
92 |
--------------------------------------------------------------------------------
/frontend/src/components/HostPage/HostCard.tsx:
--------------------------------------------------------------------------------
1 | import { apiDelHost, apiEditHost, apiWOL } from "../../functions/api";
2 |
3 | import { debounce } from "@solid-primitives/scheduled";
4 |
5 | function HostCard(_props: any) {
6 |
7 | let name:string = "";
8 |
9 | const debouncedApi = debounce(async (val: string) => {
10 | await apiEditHost(_props.host.ID, val, "");
11 | }, 300);
12 |
13 | const handleInput = async (n: string) => {
14 |
15 | name = n;
16 | debouncedApi(n);
17 | };
18 |
19 | const handleToggle = async () => {
20 |
21 | if (name == "") {
22 | name = _props.host.Name;
23 | }
24 |
25 | await apiEditHost(_props.host.ID, name, 'toggle');
26 | };
27 |
28 | const handleDel = async () => {
29 |
30 | await apiDelHost(_props.host.ID);
31 | window.location.href = '/';
32 | };
33 |
34 | const handleWOL = async () => {
35 |
36 | await apiWOL(_props.host.Mac);
37 | };
38 |
39 | return (
40 | Scan settings
10 |
11 |
78 |
79 |
41 |
111 | )
112 | }
113 |
114 | export default HostCard
--------------------------------------------------------------------------------
/backend/internal/api/api-hosts.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "log/slog"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 |
9 | "github.com/aceberg/WatchYourLAN/internal/check"
10 | "github.com/aceberg/WatchYourLAN/internal/gdb"
11 | "github.com/aceberg/WatchYourLAN/internal/models"
12 | )
13 |
14 | // getAllHosts godoc
15 | // @Summary Get all hosts
16 | // @Description Retrieve all hosts from the database
17 | // @Tags hosts
18 | // @Produce json
19 | // @Success 200 {array} models.Host
20 | // @Router /all [get]
21 | func getAllHosts(c *gin.Context) {
22 | allHosts, _ := gdb.Select("now")
23 | c.IndentedJSON(http.StatusOK, allHosts)
24 | }
25 |
26 | // getHost godoc
27 | // @Summary Get host by ID
28 | // @Description Retrieve detailed information about a host by its unique ID
29 | // @Tags hosts
30 | // @Produce json
31 | // @Param id path string true "Host ID"
32 | // @Success 200 {object} models.Host
33 | // @Router /host/{id} [get]
34 | func getHost(c *gin.Context) {
35 | idStr := c.Param("id")
36 | host := getHostByID(idStr) // functions.go
37 | _, host.DNS = check.DNS(host)
38 | c.IndentedJSON(http.StatusOK, host)
39 | }
40 |
41 | // delHost godoc
42 | // @Summary Delete host
43 | // @Description Remove a host from the database by its unique ID
44 | // @Tags hosts
45 | // @Produce json
46 | // @Param id path string true "Host ID"
47 | // @Success 200 {string} string "OK"
48 | // @Router /host/del/{id} [get]
49 | func delHost(c *gin.Context) {
50 | idStr := c.Param("id")
51 | host := getHostByID(idStr) // functions.go
52 | gdb.Delete("now", host.ID)
53 | slog.Info("Deleting from DB", "host", host)
54 | c.IndentedJSON(http.StatusOK, "OK")
55 | }
56 |
57 | // addHost godoc
58 | // @Summary Add host manually
59 | // @Description Add host by MAC, with optional Name, IP, Hardware
60 | // @Description Returns `models.Host` with this MAC form DB, either just added or existing
61 | // @Tags hosts
62 | // @Produce json
63 | // @Param mac path string true "Host MAC"
64 | // @Param name query string false "Name"
65 | // @Param ip query string false "IP"
66 | // @Param hw query string false "Hardware"
67 | // @Success 200 {object} models.Host
68 | // @Router /host/add/{mac} [get]
69 | func addHost(c *gin.Context) {
70 |
71 | mac := c.Param("mac")
72 | hosts := gdb.SelectByMAC("now", mac)
73 |
74 | if len(hosts) > 0 {
75 | slog.Warn("Host with this MAC already exists", "host", hosts[0])
76 | } else {
77 | var host models.Host
78 |
79 | host.Mac = mac
80 | host.Name = c.Query("name")
81 | host.IP = c.Query("ip")
82 | host.Hw = c.Query("hw")
83 |
84 | gdb.Update("now", host)
85 | hosts = gdb.SelectByMAC("now", mac)
86 |
87 | slog.Info("Added host to DB", "host", hosts[0])
88 | }
89 |
90 | c.IndentedJSON(http.StatusOK, hosts[0])
91 | }
92 |
93 | // editHost godoc
94 | // @Summary Edit host
95 | // @Description Update a host's name and optionally toggle its "known" status
96 | // @Tags hosts
97 | // @Produce json
98 | // @Param id path string true "Host ID"
99 | // @Param name path string true "New name for the host"
100 | // @Param known path string false "Pass 'toggle' to flip the known/unknown status"
101 | // @Success 200 {string} string "OK"
102 | // @Router /edit/{id}/{name}/{known} [get]
103 | func editHost(c *gin.Context) {
104 |
105 | idStr := c.Param("id")
106 | name := c.Param("name")
107 | toggleKnown := c.Param("known")
108 |
109 | host := getHostByID(idStr) // functions.go
110 |
111 | host.Name = name
112 |
113 | if toggleKnown == "/toggle" {
114 | host.Known = 1 - host.Known
115 | }
116 |
117 | gdb.Update("now", host)
118 |
119 | c.IndentedJSON(http.StatusOK, "OK")
120 | }
121 |
--------------------------------------------------------------------------------
/backend/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/aceberg/WatchYourLAN
2 |
3 | go 1.25.1
4 |
5 | require (
6 | github.com/aceberg/gorm-sqlite v1.6.0
7 | github.com/gin-gonic/gin v1.10.1
8 | github.com/influxdata/influxdb-client-go/v2 v2.14.0
9 | github.com/linde12/gowol v0.0.0-20180926075039-797e4d01634c
10 | github.com/nicholas-fedor/shoutrrr v0.8.18
11 | github.com/prometheus/client_golang v1.23.1
12 | github.com/spf13/viper v1.20.1
13 | github.com/swaggo/files v1.0.1
14 | github.com/swaggo/gin-swagger v1.6.1
15 | github.com/swaggo/swag v1.16.6
16 | gorm.io/driver/postgres v1.6.0
17 | gorm.io/gorm v1.30.3
18 | )
19 |
20 | require (
21 | github.com/KyleBanks/depth v1.2.1 // indirect
22 | github.com/PuerkitoBio/purell v1.1.1 // indirect
23 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
24 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
25 | github.com/beorn7/perks v1.0.1 // indirect
26 | github.com/bytedance/sonic v1.11.6 // indirect
27 | github.com/bytedance/sonic/loader v0.1.1 // indirect
28 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
29 | github.com/cloudwego/base64x v0.1.4 // indirect
30 | github.com/cloudwego/iasm v0.2.0 // indirect
31 | github.com/dustin/go-humanize v1.0.1 // indirect
32 | github.com/fatih/color v1.18.0 // indirect
33 | github.com/fsnotify/fsnotify v1.9.0 // indirect
34 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect
35 | github.com/gin-contrib/sse v0.1.0 // indirect
36 | github.com/go-openapi/jsonpointer v0.19.5 // indirect
37 | github.com/go-openapi/jsonreference v0.19.6 // indirect
38 | github.com/go-openapi/spec v0.20.4 // indirect
39 | github.com/go-openapi/swag v0.19.15 // indirect
40 | github.com/go-playground/locales v0.14.1 // indirect
41 | github.com/go-playground/universal-translator v0.18.1 // indirect
42 | github.com/go-playground/validator/v10 v10.20.0 // indirect
43 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
44 | github.com/goccy/go-json v0.10.2 // indirect
45 | github.com/google/uuid v1.6.0 // indirect
46 | github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
47 | github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
48 | github.com/jackc/pgpassfile v1.0.0 // indirect
49 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
50 | github.com/jackc/pgx/v5 v5.6.0 // indirect
51 | github.com/jackc/puddle/v2 v2.2.2 // indirect
52 | github.com/jinzhu/inflection v1.0.0 // indirect
53 | github.com/jinzhu/now v1.1.5 // indirect
54 | github.com/josharian/intern v1.0.0 // indirect
55 | github.com/json-iterator/go v1.1.12 // indirect
56 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect
57 | github.com/leodido/go-urn v1.4.0 // indirect
58 | github.com/mailru/easyjson v0.7.7 // indirect
59 | github.com/mattn/go-colorable v0.1.14 // indirect
60 | github.com/mattn/go-isatty v0.0.20 // indirect
61 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
62 | github.com/modern-go/reflect2 v1.0.2 // indirect
63 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
64 | github.com/ncruces/go-strftime v0.1.9 // indirect
65 | github.com/oapi-codegen/runtime v1.0.0 // indirect
66 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect
67 | github.com/prometheus/client_model v0.6.2 // indirect
68 | github.com/prometheus/common v0.66.0 // indirect
69 | github.com/prometheus/procfs v0.16.1 // indirect
70 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
71 | github.com/sagikazarmark/locafero v0.10.0 // indirect
72 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
73 | github.com/spf13/afero v1.14.0 // indirect
74 | github.com/spf13/cast v1.9.2 // indirect
75 | github.com/spf13/pflag v1.0.9 // indirect
76 | github.com/subosito/gotenv v1.6.0 // indirect
77 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
78 | github.com/ugorji/go/codec v1.2.12 // indirect
79 | golang.org/x/arch v0.8.0 // indirect
80 | golang.org/x/crypto v0.41.0 // indirect
81 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
82 | golang.org/x/mod v0.27.0 // indirect
83 | golang.org/x/net v0.43.0 // indirect
84 | golang.org/x/sync v0.16.0 // indirect
85 | golang.org/x/sys v0.35.0 // indirect
86 | golang.org/x/text v0.28.0 // indirect
87 | golang.org/x/tools v0.36.0 // indirect
88 | google.golang.org/protobuf v1.36.8 // indirect
89 | gopkg.in/yaml.v2 v2.4.0 // indirect
90 | gopkg.in/yaml.v3 v3.0.1 // indirect
91 | modernc.org/libc v1.65.10 // indirect
92 | modernc.org/mathutil v1.7.1 // indirect
93 | modernc.org/memory v1.11.0 // indirect
94 | modernc.org/sqlite v1.38.0 // indirect
95 | )
96 |
--------------------------------------------------------------------------------
/backend/internal/web/public/assets/HostPage.js:
--------------------------------------------------------------------------------
1 | import{u as rt,t as u,i as e,j as I,b as p,s as K,v as B,w as st,x as dt,h as N,c as m,e as C,F as ct,y as ot,o as z,k as $t,z as ut,A as bt,B as ht}from"./index.js";import{M as ft}from"./MacHistory.js";var gt=u('Host
42 |
43 |
110 |
|