Compare commits

..

108 commits
2.0.0 ... main

Author SHA1 Message Date
aceberg
ca9f644bcc Release 2.1.4 (#72,#74,#135,#195,#196) 2025-09-10 19:06:56 +07:00
aceberg
847b4caaf8 Fix WOL button 2025-09-10 18:30:21 +07:00
aceberg
24ee0acb45 Add host from API, trigger rescan 2025-09-07 03:33:42 +07:00
aceberg
1271b7b42e Delete selected hosts 2025-09-07 01:24:15 +07:00
aceberg
92e9a80770 About card upd 2025-09-06 03:15:53 +07:00
aceberg
c615198560 Swagger API docs 2025-09-06 01:23:39 +07:00
aceberg
f1bb8166ba Move backend to backend dir 2025-09-05 06:10:16 +07:00
aceberg
dee79fc74f Wake-on-LAN frontend 2025-09-05 02:40:00 +07:00
aceberg
6781adb430 Wake-on-LAN 2025-09-05 02:10:58 +07:00
aceberg
2544a3f390 README upd 2025-08-05 04:03:31 +07:00
aceberg
d314297fa2 README and API doc 2025-07-27 02:19:17 +07:00
aceberg
f6977e290b Release 2.1.3 (#149,#187,#197,#198) 2025-07-26 21:17:07 +07:00
aceberg
48aba334a0 GORM logger timeout 2025-07-26 20:52:25 +07:00
aceberg
4d820890bb Logs and Conf 2025-07-26 17:03:27 +07:00
aceberg
595f04540b History GUI upd 2025-07-26 02:27:56 +07:00
aceberg
e6f3b150ab History GUI upd 2025-07-25 02:11:12 +07:00
aceberg
5c449f76ba History Optimization 2025-07-24 16:03:15 +07:00
aceberg
1f924034b0 New Shoutrrr 2025-07-24 09:13:26 +07:00
aceberg
6b2851264f Performance optimization 2025-07-24 06:26:53 +07:00
aceberg
24afe6c96f GORM without CGO 2025-07-24 03:57:43 +07:00
aceberg
c180ea2a6a GORM fix 2025-07-24 01:38:58 +07:00
aceberg
c23be08cca GORM 2025-07-24 01:27:02 +07:00
aceberg
b51161bb3e Optimize Backend 2025-07-23 21:30:18 +07:00
aceberg
2957a51613 README upd 2025-04-23 18:02:25 +07:00
aceberg
7743c41f10 FAQ.md 2025-04-22 19:35:53 +07:00
aceberg
58453a7d8f Init file for Alpine 2025-03-31 16:07:09 +07:00
aceberg
f68cd0ddd6 Init file for Alpine 2025-03-30 13:25:22 +07:00
aceberg
f161140e94 Release 2.1.2 2025-03-30 12:57:44 +07:00
aceberg
47d5881b56 Bug fixes in GUI 2025-03-30 07:59:46 +07:00
aceberg
74c26d08a8 Release 2.1.1 2025-03-26 00:19:56 +07:00
aceberg
131efe7f36 Release 2.1.0 (#180,#181) 2025-03-25 13:44:26 +07:00
aceberg
bf075aabc2 New dev workflow 2025-03-25 01:45:56 +07:00
Andrew Erlikh
9b78b7115f
Merge pull request #180 from gabe565/optimize-docker-build
Optimize Docker build
2025-03-25 01:36:48 +07:00
aceberg
ce570b46bd Highlight sorted field 2025-03-25 00:18:19 +07:00
aceberg
5055cd3d00 UI upd 2025-03-24 17:59:48 +07:00
aceberg
0cd7cc2730 Remove old files 2025-03-24 00:28:40 +07:00
aceberg
5d2e77dd42 Small UI updates 2025-03-24 00:06:04 +07:00
aceberg
022f266038 Vite fixed file names 2025-03-23 14:23:21 +07:00
aceberg
ffc2d2fdbe History Page 2025-03-23 13:42:38 +07:00
aceberg
113cf7cb18 Config Page 2025-03-22 19:28:31 +07:00
Andrew Erlikh
8f122db640
Merge pull request #181 from gabe565/add-prometheus
Add Prometheus instrumentation
2025-03-21 15:42:59 +07:00
Andrew Erlikh
70f2a9aeee
Merge branch 'main' into add-prometheus 2025-03-21 15:42:34 +07:00
aceberg
545eac368e Header 2025-03-21 15:34:49 +07:00
aceberg
a5663ec7d4 Merge branch 'main' of github.com:aceberg/WatchYourLAN into main 2025-03-19 00:46:50 +07:00
aceberg
d17b324e6e Makefile upd 2025-03-19 00:46:23 +07:00
Gabe Cook
b31107bc42 Remove time/tzdata dependency 2025-03-18 22:06:48 +07:00
aceberg
6e57809d97 Move backend to root 2025-03-18 22:04:12 +07:00
aceberg
745dd0c2b1 Host page 2025-03-18 21:29:58 +07:00
Gabe Cook
93852060cc
Use native Go cross-compilation 2025-03-17 22:38:00 -05:00
Gabe Cook
856c2fc514
Simplify Prometheus metric init 2025-03-17 10:59:02 -05:00
aceberg
a9972ac0ce Sort 2025-03-17 21:38:57 +07:00
Andrew Erlikh
37bd7ca198
Merge pull request #177 from gabe565/remove-time-tzdata
Remove `time/tzdata` dependency
2025-03-17 21:24:34 +07:00
Gabe Cook
e8428677a4
Add Prometheus config section 2025-03-17 03:46:03 -05:00
Gabe Cook
3e416925da
Add Prometheus metrics endpoint 2025-03-17 03:23:56 -05:00
Gabe Cook
a2fd352bf1
Use GitHub Actions cache for Docker build 2025-03-17 01:36:46 -05:00
Gabe Cook
0b34f6181f
Install dependencies in a separate step 2025-03-17 01:20:15 -05:00
Gabe Cook
92f89bb313
Build a smaller Docker image 2025-03-17 00:40:14 -05:00
Gabe Cook
07637a7c94
Remove time/tzdata dependency 2025-03-17 00:32:48 -05:00
aceberg
fb1122c03d Table Row 2025-03-17 09:31:51 +07:00
aceberg
4fe2e36c77 Body Table 2025-03-16 23:25:04 +07:00
aceberg
eda622b6e3 SolidJS, split back/front 2025-03-15 18:38:41 +07:00
aceberg
a3d86cb7d7 README: new Integrations 2024-12-05 22:48:18 +07:00
aceberg
9c7809a513 Auth upd 2024-11-05 19:35:10 +07:00
aceberg
04c445cf77 Auth 2024-11-03 17:59:36 +07:00
aceberg
16f30a3159 Auth 2024-11-03 17:50:42 +07:00
aceberg
0da9fcfd97 Switch from v2 to latest 2024-10-22 00:30:25 +07:00
aceberg
eb96dabb06 Readme upd 2024-10-21 13:31:06 +07:00
aceberg
a4f88303d1 Version fix 2024-10-21 02:16:00 +07:00
aceberg
638e57b107 Release 2.0.4 (#101,#140,#147,#148) 2024-10-21 01:54:22 +07:00
aceberg
6ab27978f0 Dev Action upd2 2024-10-20 19:05:42 +07:00
aceberg
f7b9f87a09 Dev Action upd 2024-10-20 18:59:57 +07:00
aceberg
1e1f337ecd API: status 2024-10-20 18:31:12 +07:00
Andrew Erlikh
9132401ce0
Merge pull request #150 from tesladodger/feat/notify-test
Add button to test notifications
2024-10-20 16:06:31 +07:00
André Martins
585ee35c59 Add button to send test notification 2024-10-18 17:56:41 +01:00
André Martins
6e2511bee7 Add endpoint to send test notification 2024-10-18 17:56:19 +01:00
aceberg
53dcf3cc38 Fix double select for Theme, Color, Log level 2024-09-20 23:55:08 +07:00
Andrew Erlikh
d26ff73581
Merge pull request #138 from julenbujanda/bugfix/dbconfig
Fix selected DB listed twice in Config
2024-09-20 23:41:10 +07:00
Julen
67bfbe67a4 Fix selected DB listed twice in Config 2024-09-19 18:31:51 +01:00
aceberg
46ed12b4af README upd Shoutrrr 2024-09-18 19:40:41 +07:00
aceberg
3fccc2f149 Release 2.0.3 2024-09-17 23:20:38 +07:00
aceberg
2934878b5a Optimized History Trim 2024-09-16 17:50:51 +07:00
aceberg
ee850d2576 Fix ARP_STRS_JOINED in config file 2024-09-08 23:02:36 +07:00
aceberg
b646fb74e3 Release 2.0.2 (#123,#124,#128) 2024-09-07 21:21:17 +07:00
aceberg
a76eb3ffa2 Auto refresh sticky 2024-09-07 21:10:48 +07:00
Andrew Erlikh
b37d5c2a9b
Merge pull request #128 from minetaro12/scanui-fix
Changed to clear previous results when starting port scan
2024-09-07 21:02:35 +07:00
minetaro12
30532a0e32
Changed to clear previous results when starting port scan 2024-09-07 21:21:04 +09:00
Andrew Erlikh
1eaf237ba6
Merge pull request #125 from teh-hippo/issue-124
Replace empty names with 'unknown' when pushing to InfluxDB.
2024-09-07 18:22:57 +07:00
aceberg
ada1b98598 Sticky sort bug fix 2024-09-07 18:17:30 +07:00
teh-hippo
0d0e209661 Replace empty names with 'unknown' when pushing to InfluxDB. 2024-09-06 15:22:41 +10:00
aceberg
cd6d521ed1 README upd 2024-09-04 22:39:52 +07:00
aceberg
ebbc899f8e README upd 2024-09-04 22:37:10 +07:00
aceberg
936d4aa312 Fix err on empty IFACES 2024-09-04 22:34:09 +07:00
aceberg
246b35dbaf README upd 2024-09-02 22:08:28 +07:00
aceberg
93dec3961a Release 2.0.1 (#47,#103,#104,#109,#110) 2024-09-02 21:24:01 +07:00
aceberg
180e46a934 README for 2.0.1 2024-09-02 21:03:03 +07:00
aceberg
78772e9238 Vlan README 2024-09-02 19:51:49 +07:00
Andrew Erlikh
74b1b3cb2e
Merge pull request #110 from SteveClement/safari_option_fix
chg: [html] replaced option with data tag
2024-09-02 14:59:28 +07:00
aceberg
2253ebc25d Arp Strings from env try 2024-09-02 14:52:36 +07:00
Steve Clement
f756f770eb chg: [html] replaced option with data tag 2024-09-02 09:24:51 +02:00
Andrew Erlikh
3d5b86cf4f
Merge pull request #109 from SteveClement/target_blank
chg: [html] Added target="_blank" to avoid losing ongoing scan data
2024-09-01 21:55:06 +07:00
aceberg
795a46fd3c Arp Strings 2024-09-01 21:53:19 +07:00
Steve Clement
c1d4f7c60a fix: [html] <option> tag wants to be inside <select> tag 2024-09-01 12:43:46 +02:00
Steve Clement
4fbe384c80 chg: [html] Added target="_blank" to avoid losing ongoing scan data for example
fix: [html] target="blank" is not correct
2024-09-01 12:07:28 +02:00
aceberg
01323c6c65 Remember sort field, fix #103 2024-09-01 16:17:43 +07:00
Andrew Erlikh
70581a6271
Merge pull request #102 from jauderho/patch-1
Update README.md
2024-09-01 16:14:46 +07:00
Jauder Ho
78641c8a91
Update README.md
Fixed small typo.
2024-08-31 15:11:43 -07:00
aceberg
7b45306415 Try arp-scan strings 2024-08-31 23:27:48 +07:00
aceberg
03846c4365 Screenshots 2024-08-31 20:59:59 +07:00
145 changed files with 7175 additions and 2197 deletions

View file

@ -8,7 +8,7 @@ on:
jobs:
generate:
name: Create release-artifacts
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout the repository
uses: actions/checkout@master
@ -18,10 +18,11 @@ jobs:
go-version: 'stable'
- run: go version
- uses: goreleaser/goreleaser-action@v5
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
workdir: backend
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -23,4 +23,20 @@ jobs:
tags: ${{ env.TAGS }}
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Login to GHCR
# uses: docker/login-action@v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
# - name: Build and push
# uses: docker/build-push-action@v6
# with:
# context: .
# platforms: linux/amd64
# push: true
# tags: |
# ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.TAGS }}

View file

@ -13,7 +13,7 @@ env:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
@ -52,6 +52,10 @@ jobs:
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:v2
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:v2
ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max

52
.github/workflows/new-dev-docker.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: New-Dev-Docker
on:
workflow_dispatch:
# push:
# branches: [ "main" ]
# paths:
# - 'Dockerfile'
# - 'src/**'
env:
IMAGE_NAME: watchyourlan
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:dev
ghcr.io/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:dev
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -1 +1 @@
internal/web/public/version
backend/internal/web/public/version

View file

@ -1,6 +1,81 @@
# Change Log
All notable changes to this project will be documented in this file.
## [v2.1.4] - 2025-09-10
### Added
- Swagger API docs (`/swagger/index.html`)
- Add host from API [#72](https://github.com/aceberg/WatchYourLAN/issues/72)
- Trigger rescan from API or by pressing `Save` on `Config/Scan settings` [#74](https://github.com/aceberg/WatchYourLAN/issues/74)
- Delete selected hosts [#195](https://github.com/aceberg/WatchYourLAN/issues/195)
- Wake-on-LAN [#135](https://github.com/aceberg/WatchYourLAN/issues/135), [#196](https://github.com/aceberg/WatchYourLAN/issues/196)
## [v2.1.3] - 2025-07-26
### Fixed
- Memory leak bug [#149](https://github.com/aceberg/WatchYourLAN/issues/149)
- Duplicated devices bug [#187](https://github.com/aceberg/WatchYourLAN/issues/187) [#198](https://github.com/aceberg/WatchYourLAN/issues/198)
### Changed
- **DEPRECATED:** `HIST_IN_DB` config option. Now history is always stored in `DB`
- Upd to `go 1.24.5`
- Moved `DB` handling to `GORM`
- Moved to maintained `Shoutrrr`: [github.com/nicholas-fedor/shoutrrr](https://github.com/nicholas-fedor/shoutrrr) ([#197](https://github.com/aceberg/WatchYourLAN/issues/197))
## [v2.1.2] - 2025-03-30
### Fixed
- Edit names bug
- History page full rerenders replaced with only rerendering updated data
- Select options reset
## [v2.1.1] - 2025-03-26
### Fixed
- Filter bug in Chrome
## [v2.1.0] - 2025-03-25
### Added
- Rewrited GUI in `SolidJS` and `TypeScript`
- Prometheus integration [#181](https://github.com/aceberg/WatchYourLAN/pull/181)
- Optimized Docker build [#180](https://github.com/aceberg/WatchYourLAN/pull/180)
### Fixed
- Vite: file names
- Node Path bug
## [v2.0.4] - 2024-10-21
### Added
- Notification test [#147](https://github.com/aceberg/WatchYourLAN/issues/147)
- API status [#148](https://github.com/aceberg/WatchYourLAN/issues/148)
### Fixed
- [#101](https://github.com/aceberg/WatchYourLAN/issues/101)
- The same problem for Theme, Color mode, Log level
- Sort bug in Chrome [#140](https://github.com/aceberg/WatchYourLAN/issues/140)
## [v2.0.3] - 2024-09-17
### Fixed
- `ARP_STRS_JOINED` should be empty in config file
- Optimized History Trim
## [v2.0.2] - 2024-09-07
### Added
- Remember Refresh setting in browser [#123](https://github.com/aceberg/WatchYourLAN/issues/123)
### Fixed
- Error when `IFACES` are empty
- Sticky sort bug fix
- Bug [#124](https://github.com/aceberg/WatchYourLAN/issues/124)
- Bug [#128](https://github.com/aceberg/WatchYourLAN/issues/128)
## [v2.0.1] - 2024-09-02
### Added
- `Vlans` and `docker0` support [#47](https://github.com/aceberg/WatchYourLAN/issues/47). Thanks [thehijacker](https://github.com/thehijacker)!
- Remember `sort` field
- `InfluxDB` error handling
### Fixed
- Bug [#103](https://github.com/aceberg/WatchYourLAN/issues/103)
- Bug [#104](https://github.com/aceberg/WatchYourLAN/issues/104). Thanks [Steve Clement](https://github.com/SteveClement)!
## [v2.0.0] - 2024-08-30
### Added
- API

View file

@ -1,8 +1,18 @@
FROM golang:alpine AS builder
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
RUN apk add build-base
COPY . /src
RUN cd /src/cmd/WatchYourLAN/ && CGO_ENABLED=0 go build -o /WatchYourLAN .
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
COPY --from=xx / /
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
ARG TARGETPLATFORM
RUN CGO_ENABLED=0 xx-go build -ldflags='-w -s' -o /WatchYourLAN ./cmd/WatchYourLAN
FROM alpine
@ -14,4 +24,4 @@ RUN apk add --no-cache arp-scan tzdata \
COPY --from=builder /WatchYourLAN /app/
ENTRYPOINT ["./WatchYourLAN"]
ENTRYPOINT ["./WatchYourLAN"]

18
FAQ.md Normal file
View file

@ -0,0 +1,18 @@
# FAQ
## Allow custom MAC vendor overrides
Issues [#169](https://github.com/aceberg/WatchYourLAN/issues/169), [#185](https://github.com/aceberg/WatchYourLAN/issues/185)
WatchYourLAN is using `arp-scan`, so most of its options are available to WYL users.
1. Prepare a [mac-vendor.txt](https://manpages.debian.org/testing/arp-scan/mac-vendor.5.en.html) file with additional MACs and put it in a mounted WYL directory.
2. If you are using `IFACES` variable to define interfaces, add path to mac-vendor.txt to `ARP_ARGS`
```yaml
arp_args: --macfile=/data/WatchYourLAN/mac-vendor.txt
```
3. For interfaces defined in `ARP_STRS` add the same directly in the beginning of `ARP_STRS` string
```yaml
arp_strs:
- --macfile=/data/WatchYourLAN/mac-vendor.txt -gNx 10.144.0.1/24 -I eth0
```
4. **WARNING!** To see an updated vendor, you'll have to delete host and wait for the next scan.

View file

@ -5,19 +5,33 @@
[![Docker](https://github.com/aceberg/WatchYourLAN/actions/workflows/main-docker-all.yml/badge.svg)](https://github.com/aceberg/WatchYourLAN/actions/workflows/main-docker-all.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/aceberg/WatchYourLAN)](https://goreportcard.com/report/github.com/aceberg/WatchYourLAN)
[![Maintainability](https://api.codeclimate.com/v1/badges/46b17f99edc1726b5d7d/maintainability)](https://codeclimate.com/github/aceberg/WatchYourLAN/maintainability)
![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/aceberg/watchyourlan)
![Docker Pulls](https://img.shields.io/docker/pulls/aceberg/watchyourlan)
[![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/aceberg/watchyourlan)](https://hub.docker.com/r/aceberg/watchyourlan)
[![GitHub Discussions](https://img.shields.io/github/discussions/aceberg/WatchYourLAN)](https://github.com/aceberg/WatchYourLAN/discussions)
Lightweight network IP scanner with web GUI
> [!WARNING]
> This is version 2.0. Version 1.0 can be found in this brunch: [v1](https://github.com/aceberg/WatchYourLAN/tree/v1)
<a href="https://trendshift.io/repositories/11642" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11642" alt="aceberg%2FWatchYourLAN | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
> [!CAUTION]
> **BREAKING CHANGES!** Version 2.0 is not compatible with v1.0. For now v2.0 docker images will be released under `v2` tag. It will be tagged `latest` in a few weeks (probably, in October).
Lightweight network IP scanner with web GUI. Features:
- Send notification when new host is found
- Monitor hosts online/offline history
- Keep a list of all hosts in the network
- Send data to `InfluxDB2` or `Prometheus` to make a `Grafana` dashboard
> [!IMPORTANT]
> Please, consider making a [donation](https://github.com/aceberg#donate). Even $10 will make a difference to me.
![Screenshot_1](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_1.png)
## More screenshots
<details>
<summary>Expand</summary>
![Screenshot_5](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_5.png)
![Screenshot_2](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_2.png)
![Screenshot_3](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_3.png)
![Screenshot_4](https://raw.githubusercontent.com/aceberg/WatchYourLAN/main/assets/Screenshot_4.png)
</details>
## Quick start
<details>
@ -31,17 +45,44 @@ docker run --name wyl \
-e "TZ=$YOURTIMEZONE" \
--network="host" \
-v $DOCKERDATAPATH/wyl:/data/WatchYourLAN \
aceberg/watchyourlan:v2
aceberg/watchyourlan
```
Web GUI should be at http://localhost:8840
</details>
## Auth
<details>
<summary>Expand</summary>
**WatchYourLAN** does not have built-in auth option. But you can use it with SSO tools like Authelia, or my simple auth app [ForAuth](https://github.com/aceberg/ForAuth).
Here is an example [docker-compose-auth.yml](https://github.com/aceberg/WatchYourLAN/blob/main/docker-compose-auth.yml).
> :warning: **WARNING!**
> Please, don't forget that 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.
</details>
## Install on Linux
<details>
<summary>Expand</summary>
All binary packages can be found in [latest](https://github.com/aceberg/WatchYourLAN/releases/latest) release. There are `.deb`, `.rpm`, `.apk` (Alpine Linux) and `.tar.gz` files.
Supported architectures: `amd64`, `i386`, `arm_v5`, `arm_v6`, `arm_v7`, `arm64`.
Dependencies: `arp-scan`, `tzdata`.
For `amd64` there is a `deb` repo [available](https://github.com/aceberg/ppa)
</details>
## Config
<details>
<summary>Expand</summary>
Configuration can be done through config file, GUI or environment variables
Configuration can be done through config file, GUI or environment variables. Variable names is `config_v2.yaml` file are the same, but in lowcase.
### Basic config
| Variable | Description | Default |
@ -52,17 +93,18 @@ Configuration can be done through config file, GUI or environment variables
| THEME | Any theme name from https://bootswatch.com in lowcase or [additional](https://github.com/aceberg/aceberg-bootswatch-fork) | sand |
| COLOR | Background color: light or dark | dark |
| NODEPATH | Path to local node modules | |
| SHOUTRRR_URL | Link to any notification service supported by [Shoutrrr](https://github.com/containrrr/shoutrrr) (gotify, email, telegram and others) or [Generic Webhook](https://github.com/containrrr/shoutrrr/blob/main/docs/services/generic.md) | |
| SHOUTRRR_URL | WatchYourLAN uses [Shoutrrr](https://github.com/nicholas-fedor/shoutrrr) to send notifications. It is already integrated, just needs a correct URL. Examples for Discord, Email, Gotify, Matrix, Ntfy, Pushover, Slack, Telegram, Generic Webhook and etc are [here](https://nicholas-fedor.github.io/shoutrrr/) | |
### Scan settings
| Variable | Description | Default |
| -------- | ----------- | ------- |
| IFACES | Interfaces to scan. Could be one or more, separated by space. Currently `docker0` is not supported, as `arp-scan` wouldn't work with it correctly | |
| IFACES | Interfaces to scan. Could be one or more, separated by space. See [docs/VLAN_ARP_SCAN.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md). | |
| TIMEOUT | Time between scans (seconds) | 120 |
| ARP_ARGS | Arguments for `arp-scan`. See `man arp-scan` for more. Enable `debug` log level to see resulting command. (Example: `-r 1`) | |
| ARP_ARGS | Arguments for `arp-scan`. Enable `debug` log level to see resulting command. (Example: `-r 1`). See [docs/VLAN_ARP_SCAN.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md). | |
| ARP_STRS ARP_STRS_JOINED | See [docs/VLAN_ARP_SCAN.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md). | |
| LOG_LEVEL | Log level: `debug`, `info`, `warn` or `error` | info |
| TRIM_HIST | Remove history after (hours) | 48 |
| HIST_IN_DB | Store History in DB - if `false`, the History will be stored only in memory and will be lost on app restart. Though, it will keep the app DB smaller (and InfluxDB is recommended for long term History storage) | false |
| HIST_IN_DB | DEPRECATED since 2.1.3. Now History is always stored in DB. Use TRIM_HIST to reduce DB size | |
| USE_DB | Either `sqlite` or `postgres` | sqlite |
| PG_CONNECT | Address to connect to PostgreSQL. (Example: `postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable`). Full list of URL parameters [here](https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters) | |
@ -78,6 +120,13 @@ This config matches Grafana's config for InfluxDB data source
| INFLUX_ORG | InfluxDB2 org | | home |
| INFLUX_TOKEN | Secret token, generated by InfluxDB2 | | |
### Prometheus config
This config configures the Prometheus data source
| Variable | Description | Default | Example |
| -------- | ----------- | ------- | ------- |
| PROMETHEUS_ENABLE | Enable the Prometheus `/metrics` endpoint | false | true |
</details>
## Config file
@ -90,7 +139,6 @@ Config file name is `config_v2.yaml`. Example:
```yaml
arp_args: ""
color: dark
hist_in_db: false
host: 0.0.0.0
ifaces: enp4s0
influx_addr: ""
@ -103,6 +151,7 @@ log_level: info
nodepath: ""
pg_connect: ""
port: "8840"
prometheus_enable: false
shoutrrr_url: "gotify://192.168.0.1:8083/AwQqpAae.rrl5Ob/?title=Unknown host detected&DisableTLS=yes"
theme: sand
timeout: 60
@ -141,18 +190,25 @@ docker run --name wyl \
-e "TZ=$YOURTIMEZONE" \
--network="host" \
-v $DOCKERDATAPATH/wyl:/data/WatchYourLAN \
aceberg/watchyourlan:v2 -n "http://$YOUR_IP:8850"
aceberg/watchyourlan -n "http://$YOUR_IP:8850"
```
Or use [docker-compose](docker-compose-local.yml)
Or use [docker-compose](docker-compose.yml)
</details>
## API
## API & Integrations
<details>
<summary>Expand</summary>
Moved to [docs/API.md](./docs/API.md)
### API
Moved to [docs/API.md](https://github.com/aceberg/WatchYourLAN/blob/main/docs/API.md)
### Integrations
- [ArchLinux (AUR)](https://aur.archlinux.org/packages/watch-your-lan) by `gilcu3`
- [Python API client](https://github.com/drwahl/py-watchyourlanclient) by [drwahl](https://github.com/drwahl)
- [Umbrel](https://apps.umbrel.com/app/watch-your-lan) by [Jasper](https://github.com/ceramicwhite)
- [YunoHost](https://apps.yunohost.org/app/watchyourlan)
</details>
## Thanks
@ -164,4 +220,4 @@ Moved to [docs/API.md](./docs/API.md)
- [Bootstrap](https://getbootstrap.com/)
- Themes: [Free themes for Bootstrap](https://bootswatch.com)
</details>
</details>

BIN
assets/Screenshot_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
assets/Screenshot_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
assets/Screenshot_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
assets/Screenshot_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -1,3 +1,4 @@
version: 2
project_name: watchyourlan
builds:
- main: ./cmd/WatchYourLAN/
@ -17,16 +18,15 @@ builds:
- "7"
nfpms:
- maintainer: aceberg <aceberg_a@proton.me>
- id: systemd
formats:
- deb
- rpm
maintainer: aceberg <aceberg_a@proton.me>
description: Lightweight network IP scanner with web GUI
homepage: https://github.com/aceberg/watchyourlan
license: MIT
section: utils
formats:
- deb
- rpm
- apk
- termux.deb
dependencies: # Don't forget to edit!
- arp-scan
- tzdata
@ -36,11 +36,24 @@ nfpms:
scripts:
postinstall: ./configs/postinstall.sh
- id: alpine
formats:
- apk
maintainer: aceberg <aceberg_a@proton.me>
description: Lightweight network IP scanner with web GUI
homepage: https://github.com/aceberg/watchyourlan
license: MIT
section: utils
dependencies: # Don't forget to edit!
- arp-scan
- tzdata
contents:
- src: ./configs/watchyourlan
dst: /etc/init.d/watchyourlan
archives:
- files:
- LICENSE
- README.md
- CHANGELOG.md
- src: ./configs/watchyourlan.service
dst: watchyourlan.service
- src: ./configs/install.sh

21
backend/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 aceberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -7,7 +7,7 @@ mod:
run:
cd cmd/WatchYourLAN/ && \
sudo \
go run . #-c /data/config #-n http://192.168.2.3:8850
go run . #-n http://192.168.2.3:8850
fmt:
go fmt ./...
@ -16,4 +16,7 @@ lint:
golangci-lint run
golint ./...
check: fmt lint
check: fmt lint
swag:
swag init -g main.go --dir cmd/WatchYourLAN,internal/api,internal/models

View file

@ -0,0 +1,52 @@
// @title WatchYourLAN API
// @version 0.1
// @description Lightweight network IP scanner written in Go
// @contact.url https://github.com/aceberg/WatchYourLAN
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @BasePath /api/
package main
import (
"flag"
// "net/http"
// _ "net/http/pprof"
// Import Swagger docs
_ "github.com/aceberg/WatchYourLAN/docs"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/gdb"
"github.com/aceberg/WatchYourLAN/internal/routines"
"github.com/aceberg/WatchYourLAN/internal/web"
)
const dirPath = "/data/WatchYourLAN"
const nodePath = ""
func main() {
dirPtr := flag.String("d", dirPath, "Path to config dir")
nodePtr := flag.String("n", nodePath, "Path to node modules")
flag.Parse()
// pprof - memory leak detect
// go tool pprof -alloc_space http://localhost:8085/debug/pprof/heap
// (pprof) web
// (pprof) list db.Select
//
// go func() {
// http.ListenAndServe("localhost:8085", nil)
// }()
// Make AppConfig
conf.Start(*dirPtr, *nodePtr)
gdb.Start()
routines.ScanRestart()
routines.HistoryTrim()
web.Gui()
}

6
backend/configs/watchyourlan Executable file
View file

@ -0,0 +1,6 @@
#!/sbin/openrc-run
name="WatchYourLAN"
description="Lightweight network IP scanner with web GUI"
command="/usr/bin/watchyourlan"
command_background=true
pidfile="/run/watchyourlan.pid"

630
backend/docs/docs.go Normal file
View file

@ -0,0 +1,630 @@
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {
"url": "https://github.com/aceberg/WatchYourLAN"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/all": {
"get": {
"description": "Retrieve all hosts from the database",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Get all hosts",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/config": {
"get": {
"description": "Returns the current configuration used by the app",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get application configuration",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Conf"
}
}
}
}
},
"/edit/{id}/{name}/{known}": {
"get": {
"description": "Update a host's name and optionally toggle its \"known\" status",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Edit host",
"parameters": [
{
"type": "string",
"description": "Host ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "New name for the host",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Pass 'toggle' to flip the known/unknown status",
"name": "known",
"in": "path"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/history": {
"get": {
"description": "Retrieve the complete history of all hosts. Not recommended, the output can be a lot",
"produces": [
"application/json"
],
"tags": [
"history"
],
"summary": "Get full history",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/history/{mac}": {
"get": {
"description": "Retrieve the latest history entries for a specific host by MAC address",
"produces": [
"application/json"
],
"tags": [
"history"
],
"summary": "Get history by MAC",
"parameters": [
{
"type": "string",
"description": "MAC address of the host",
"name": "mac",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Number of history entries to return",
"name": "num",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/history/{mac}/{date}": {
"get": {
"description": "Retrieve history for a specific host on a given date\nThe date format is flexible and can be:\n- Year only: ` + "`" + `2025` + "`" + `\n- Year + month: ` + "`" + `2025-09` + "`" + `\n- Full date: ` + "`" + `2025-09-06` + "`" + `\n- Full timestamp: ` + "`" + `2025-09-06 00:58:26` + "`" + `",
"produces": [
"application/json"
],
"tags": [
"history"
],
"summary": "Get history by date",
"parameters": [
{
"type": "string",
"description": "MAC address of the host",
"name": "mac",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Date filter (supports YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DD HH:mm:ss)",
"name": "date",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/host/add/{mac}": {
"get": {
"description": "Add host by MAC, with optional Name, IP, Hardware\nReturns ` + "`" + `models.Host` + "`" + ` with this MAC form DB, either just added or existing",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Add host manually",
"parameters": [
{
"type": "string",
"description": "Host MAC",
"name": "mac",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Name",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "IP",
"name": "ip",
"in": "query"
},
{
"type": "string",
"description": "Hardware",
"name": "hw",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Host"
}
}
}
}
},
"/host/del/{id}": {
"get": {
"description": "Remove a host from the database by its unique ID",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Delete host",
"parameters": [
{
"type": "string",
"description": "Host ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/host/{id}": {
"get": {
"description": "Retrieve detailed information about a host by its unique ID",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Get host by ID",
"parameters": [
{
"type": "string",
"description": "Host ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Host"
}
}
}
}
},
"/notify_test": {
"get": {
"description": "Trigger a test notification to verify notification settings",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Send test notification",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/port/{addr}/{port}": {
"get": {
"description": "Check whether a given TCP port on an address is open or closed",
"produces": [
"application/json"
],
"tags": [
"network"
],
"summary": "Check port state",
"parameters": [
{
"type": "string",
"description": "IP address or hostname",
"name": "addr",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Port number",
"name": "port",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "true if open, false if closed",
"schema": {
"type": "boolean"
}
}
}
}
},
"/rescan": {
"get": {
"description": "Manually trigger rescan",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Rescan all interfaces now",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/status/{iface}": {
"get": {
"description": "Retrieve summary statistics of hosts, optionally filtered by interface",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get network status",
"parameters": [
{
"type": "string",
"description": "Interface name (omit for all interfaces)",
"name": "iface",
"in": "path"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Stat"
}
}
}
}
},
"/version": {
"get": {
"description": "Returns the current running version of the application",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get application version",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/wol/{mac}": {
"get": {
"description": "Send a magic packet to wake up a host by its MAC address",
"produces": [
"application/json"
],
"tags": [
"network"
],
"summary": "Send Wake-on-LAN packet",
"parameters": [
{
"type": "string",
"description": "MAC address of the host",
"name": "mac",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "true if sent successfully",
"schema": {
"type": "boolean"
}
}
}
}
}
},
"definitions": {
"models.Conf": {
"type": "object",
"properties": {
"arpArgs": {
"type": "string"
},
"arpStrs": {
"type": "array",
"items": {
"type": "string"
}
},
"color": {
"type": "string"
},
"confPath": {
"type": "string"
},
"dbpath": {
"type": "string"
},
"dirPath": {
"type": "string"
},
"host": {
"type": "string"
},
"ifaces": {
"type": "string"
},
"influxAddr": {
"type": "string"
},
"influxBucket": {
"type": "string"
},
"influxEnable": {
"description": "InfluxDB",
"type": "boolean"
},
"influxOrg": {
"type": "string"
},
"influxSkipTLS": {
"type": "boolean"
},
"influxToken": {
"type": "string"
},
"logLevel": {
"type": "string"
},
"nodePath": {
"type": "string"
},
"pgconnect": {
"type": "string"
},
"port": {
"type": "string"
},
"prometheusEnable": {
"description": "Prometheus",
"type": "boolean"
},
"shoutURL": {
"type": "string"
},
"theme": {
"type": "string"
},
"timeout": {
"type": "integer"
},
"trimHist": {
"type": "integer"
},
"useDB": {
"description": "PostgreSQL",
"type": "string"
},
"version": {
"type": "string"
}
}
},
"models.Host": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"dns": {
"type": "string"
},
"hw": {
"type": "string"
},
"id": {
"type": "integer"
},
"iface": {
"type": "string"
},
"ip": {
"type": "string"
},
"known": {
"type": "integer"
},
"mac": {
"type": "string"
},
"name": {
"type": "string"
},
"now": {
"type": "integer"
}
}
},
"models.Stat": {
"type": "object",
"properties": {
"known": {
"type": "integer"
},
"offline": {
"type": "integer"
},
"online": {
"type": "integer"
},
"total": {
"type": "integer"
},
"unknown": {
"type": "integer"
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "0.1",
Host: "",
BasePath: "/api/",
Schemes: []string{},
Title: "WatchYourLAN API",
Description: "Lightweight network IP scanner written in Go",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

605
backend/docs/swagger.json Normal file
View file

@ -0,0 +1,605 @@
{
"swagger": "2.0",
"info": {
"description": "Lightweight network IP scanner written in Go",
"title": "WatchYourLAN API",
"contact": {
"url": "https://github.com/aceberg/WatchYourLAN"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "0.1"
},
"basePath": "/api/",
"paths": {
"/all": {
"get": {
"description": "Retrieve all hosts from the database",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Get all hosts",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/config": {
"get": {
"description": "Returns the current configuration used by the app",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get application configuration",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Conf"
}
}
}
}
},
"/edit/{id}/{name}/{known}": {
"get": {
"description": "Update a host's name and optionally toggle its \"known\" status",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Edit host",
"parameters": [
{
"type": "string",
"description": "Host ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "New name for the host",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Pass 'toggle' to flip the known/unknown status",
"name": "known",
"in": "path"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/history": {
"get": {
"description": "Retrieve the complete history of all hosts. Not recommended, the output can be a lot",
"produces": [
"application/json"
],
"tags": [
"history"
],
"summary": "Get full history",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/history/{mac}": {
"get": {
"description": "Retrieve the latest history entries for a specific host by MAC address",
"produces": [
"application/json"
],
"tags": [
"history"
],
"summary": "Get history by MAC",
"parameters": [
{
"type": "string",
"description": "MAC address of the host",
"name": "mac",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Number of history entries to return",
"name": "num",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/history/{mac}/{date}": {
"get": {
"description": "Retrieve history for a specific host on a given date\nThe date format is flexible and can be:\n- Year only: `2025`\n- Year + month: `2025-09`\n- Full date: `2025-09-06`\n- Full timestamp: `2025-09-06 00:58:26`",
"produces": [
"application/json"
],
"tags": [
"history"
],
"summary": "Get history by date",
"parameters": [
{
"type": "string",
"description": "MAC address of the host",
"name": "mac",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Date filter (supports YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DD HH:mm:ss)",
"name": "date",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Host"
}
}
}
}
}
},
"/host/add/{mac}": {
"get": {
"description": "Add host by MAC, with optional Name, IP, Hardware\nReturns `models.Host` with this MAC form DB, either just added or existing",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Add host manually",
"parameters": [
{
"type": "string",
"description": "Host MAC",
"name": "mac",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Name",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "IP",
"name": "ip",
"in": "query"
},
{
"type": "string",
"description": "Hardware",
"name": "hw",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Host"
}
}
}
}
},
"/host/del/{id}": {
"get": {
"description": "Remove a host from the database by its unique ID",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Delete host",
"parameters": [
{
"type": "string",
"description": "Host ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/host/{id}": {
"get": {
"description": "Retrieve detailed information about a host by its unique ID",
"produces": [
"application/json"
],
"tags": [
"hosts"
],
"summary": "Get host by ID",
"parameters": [
{
"type": "string",
"description": "Host ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Host"
}
}
}
}
},
"/notify_test": {
"get": {
"description": "Trigger a test notification to verify notification settings",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Send test notification",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/port/{addr}/{port}": {
"get": {
"description": "Check whether a given TCP port on an address is open or closed",
"produces": [
"application/json"
],
"tags": [
"network"
],
"summary": "Check port state",
"parameters": [
{
"type": "string",
"description": "IP address or hostname",
"name": "addr",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Port number",
"name": "port",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "true if open, false if closed",
"schema": {
"type": "boolean"
}
}
}
}
},
"/rescan": {
"get": {
"description": "Manually trigger rescan",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Rescan all interfaces now",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/status/{iface}": {
"get": {
"description": "Retrieve summary statistics of hosts, optionally filtered by interface",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get network status",
"parameters": [
{
"type": "string",
"description": "Interface name (omit for all interfaces)",
"name": "iface",
"in": "path"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Stat"
}
}
}
}
},
"/version": {
"get": {
"description": "Returns the current running version of the application",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get application version",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/wol/{mac}": {
"get": {
"description": "Send a magic packet to wake up a host by its MAC address",
"produces": [
"application/json"
],
"tags": [
"network"
],
"summary": "Send Wake-on-LAN packet",
"parameters": [
{
"type": "string",
"description": "MAC address of the host",
"name": "mac",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "true if sent successfully",
"schema": {
"type": "boolean"
}
}
}
}
}
},
"definitions": {
"models.Conf": {
"type": "object",
"properties": {
"arpArgs": {
"type": "string"
},
"arpStrs": {
"type": "array",
"items": {
"type": "string"
}
},
"color": {
"type": "string"
},
"confPath": {
"type": "string"
},
"dbpath": {
"type": "string"
},
"dirPath": {
"type": "string"
},
"host": {
"type": "string"
},
"ifaces": {
"type": "string"
},
"influxAddr": {
"type": "string"
},
"influxBucket": {
"type": "string"
},
"influxEnable": {
"description": "InfluxDB",
"type": "boolean"
},
"influxOrg": {
"type": "string"
},
"influxSkipTLS": {
"type": "boolean"
},
"influxToken": {
"type": "string"
},
"logLevel": {
"type": "string"
},
"nodePath": {
"type": "string"
},
"pgconnect": {
"type": "string"
},
"port": {
"type": "string"
},
"prometheusEnable": {
"description": "Prometheus",
"type": "boolean"
},
"shoutURL": {
"type": "string"
},
"theme": {
"type": "string"
},
"timeout": {
"type": "integer"
},
"trimHist": {
"type": "integer"
},
"useDB": {
"description": "PostgreSQL",
"type": "string"
},
"version": {
"type": "string"
}
}
},
"models.Host": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"dns": {
"type": "string"
},
"hw": {
"type": "string"
},
"id": {
"type": "integer"
},
"iface": {
"type": "string"
},
"ip": {
"type": "string"
},
"known": {
"type": "integer"
},
"mac": {
"type": "string"
},
"name": {
"type": "string"
},
"now": {
"type": "integer"
}
}
},
"models.Stat": {
"type": "object",
"properties": {
"known": {
"type": "integer"
},
"offline": {
"type": "integer"
},
"online": {
"type": "integer"
},
"total": {
"type": "integer"
},
"unknown": {
"type": "integer"
}
}
}
}
}

409
backend/docs/swagger.yaml Normal file
View file

@ -0,0 +1,409 @@
basePath: /api/
definitions:
models.Conf:
properties:
arpArgs:
type: string
arpStrs:
items:
type: string
type: array
color:
type: string
confPath:
type: string
dbpath:
type: string
dirPath:
type: string
host:
type: string
ifaces:
type: string
influxAddr:
type: string
influxBucket:
type: string
influxEnable:
description: InfluxDB
type: boolean
influxOrg:
type: string
influxSkipTLS:
type: boolean
influxToken:
type: string
logLevel:
type: string
nodePath:
type: string
pgconnect:
type: string
port:
type: string
prometheusEnable:
description: Prometheus
type: boolean
shoutURL:
type: string
theme:
type: string
timeout:
type: integer
trimHist:
type: integer
useDB:
description: PostgreSQL
type: string
version:
type: string
type: object
models.Host:
properties:
date:
type: string
dns:
type: string
hw:
type: string
id:
type: integer
iface:
type: string
ip:
type: string
known:
type: integer
mac:
type: string
name:
type: string
now:
type: integer
type: object
models.Stat:
properties:
known:
type: integer
offline:
type: integer
online:
type: integer
total:
type: integer
unknown:
type: integer
type: object
info:
contact:
url: https://github.com/aceberg/WatchYourLAN
description: Lightweight network IP scanner written in Go
license:
name: MIT
url: https://opensource.org/licenses/MIT
title: WatchYourLAN API
version: "0.1"
paths:
/all:
get:
description: Retrieve all hosts from the database
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/models.Host'
type: array
summary: Get all hosts
tags:
- hosts
/config:
get:
description: Returns the current configuration used by the app
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Conf'
summary: Get application configuration
tags:
- system
/edit/{id}/{name}/{known}:
get:
description: Update a host's name and optionally toggle its "known" status
parameters:
- description: Host ID
in: path
name: id
required: true
type: string
- description: New name for the host
in: path
name: name
required: true
type: string
- description: Pass 'toggle' to flip the known/unknown status
in: path
name: known
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: Edit host
tags:
- hosts
/history:
get:
description: Retrieve the complete history of all hosts. Not recommended, the
output can be a lot
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/models.Host'
type: array
summary: Get full history
tags:
- history
/history/{mac}:
get:
description: Retrieve the latest history entries for a specific host by MAC
address
parameters:
- description: MAC address of the host
in: path
name: mac
required: true
type: string
- description: Number of history entries to return
in: query
name: num
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/models.Host'
type: array
summary: Get history by MAC
tags:
- history
/history/{mac}/{date}:
get:
description: |-
Retrieve history for a specific host on a given date
The date format is flexible and can be:
- Year only: `2025`
- Year + month: `2025-09`
- Full date: `2025-09-06`
- Full timestamp: `2025-09-06 00:58:26`
parameters:
- description: MAC address of the host
in: path
name: mac
required: true
type: string
- description: Date filter (supports YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DD HH:mm:ss)
in: path
name: date
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/models.Host'
type: array
summary: Get history by date
tags:
- history
/host/{id}:
get:
description: Retrieve detailed information about a host by its unique ID
parameters:
- description: Host ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Host'
summary: Get host by ID
tags:
- hosts
/host/add/{mac}:
get:
description: |-
Add host by MAC, with optional Name, IP, Hardware
Returns `models.Host` with this MAC form DB, either just added or existing
parameters:
- description: Host MAC
in: path
name: mac
required: true
type: string
- description: Name
in: query
name: name
type: string
- description: IP
in: query
name: ip
type: string
- description: Hardware
in: query
name: hw
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Host'
summary: Add host manually
tags:
- hosts
/host/del/{id}:
get:
description: Remove a host from the database by its unique ID
parameters:
- description: Host ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: Delete host
tags:
- hosts
/notify_test:
get:
description: Trigger a test notification to verify notification settings
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: Send test notification
tags:
- system
/port/{addr}/{port}:
get:
description: Check whether a given TCP port on an address is open or closed
parameters:
- description: IP address or hostname
in: path
name: addr
required: true
type: string
- description: Port number
in: path
name: port
required: true
type: string
produces:
- application/json
responses:
"200":
description: true if open, false if closed
schema:
type: boolean
summary: Check port state
tags:
- network
/rescan:
get:
description: Manually trigger rescan
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: Rescan all interfaces now
tags:
- system
/status/{iface}:
get:
description: Retrieve summary statistics of hosts, optionally filtered by interface
parameters:
- description: Interface name (omit for all interfaces)
in: path
name: iface
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Stat'
summary: Get network status
tags:
- system
/version:
get:
description: Returns the current running version of the application
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: Get application version
tags:
- system
/wol/{mac}:
get:
description: Send a magic packet to wake up a host by its MAC address
parameters:
- description: MAC address of the host
in: path
name: mac
required: true
type: string
produces:
- application/json
responses:
"200":
description: true if sent successfully
schema:
type: boolean
summary: Send Wake-on-LAN packet
tags:
- network
swagger: "2.0"

95
backend/go.mod Normal file
View file

@ -0,0 +1,95 @@
module github.com/aceberg/WatchYourLAN
go 1.25.1
require (
github.com/aceberg/gorm-sqlite v1.6.0
github.com/gin-gonic/gin v1.10.1
github.com/influxdata/influxdb-client-go/v2 v2.14.0
github.com/linde12/gowol v0.0.0-20180926075039-797e4d01634c
github.com/nicholas-fedor/shoutrrr v0.8.18
github.com/prometheus/client_golang v1.23.1
github.com/spf13/viper v1.20.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.3
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oapi-codegen/runtime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.10.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
)

302
backend/go.sum Normal file
View file

@ -0,0 +1,302 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/aceberg/gorm-sqlite v1.6.0 h1:I1c4uqC82Uc7JVt2+cjushdvQogN/C1I3XOiyn3yoUI=
github.com/aceberg/gorm-sqlite v1.6.0/go.mod h1:3g//0BOzaOv9dO8G9j7JZOJA6qIURpPaht9oDY7LKpA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4=
github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI=
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/linde12/gowol v0.0.0-20180926075039-797e4d01634c h1:QRJTb9zWXQL+yUajUqbp+VLtN+DQaYRloOxNwylsuVc=
github.com/linde12/gowol v0.0.0-20180926075039-797e4d01634c/go.mod h1:YeHfx3xIWda3noSterlj6d3+PdRRCTRox269+zhLmbM=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.8.18 h1:xYVtx2ipcEoLJFWViRpndC8zuTSRbhPEehsTkEBO4NQ=
github.com/nicholas-fedor/shoutrrr v0.8.18/go.mod h1:RYx646RI7DazdYQo49IWCuMeqq+D5zC3//F6I+qhCgY=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo=
github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A=
github.com/onsi/ginkgo/v2 v2.25.2 h1:hepmgwx1D+llZleKQDMEvy8vIlCxMGt7W5ZxDjIEhsw=
github.com/onsi/ginkgo/v2 v2.25.2/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.1 h1:w6gXMLQGgd0jXXlote9lRHMe0nG01EbnJT+C0EJru2Y=
github.com/prometheus/client_golang v1.23.1/go.mod h1:br8j//v2eg2K5Vvna5klK8Ku5pcU5r4ll73v6ik5dIQ=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY=
github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.3 h1:QiG8upl0Sg9ba2Zatfjy0fy4It2iNBL2/eMdvEkdXNs=
gorm.io/gorm v1.30.3/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View file

@ -0,0 +1,60 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/aceberg/WatchYourLAN/internal/gdb"
)
// getHistory godoc
// @Summary Get full history
// @Description Retrieve the complete history of all hosts. Not recommended, the output can be a lot
// @Tags history
// @Produce json
// @Success 200 {array} models.Host
// @Router /history [get]
func getHistory(c *gin.Context) {
hosts, _ := gdb.Select("history")
c.IndentedJSON(http.StatusOK, hosts)
}
// getHistoryByMAC godoc
// @Summary Get history by MAC
// @Description Retrieve the latest history entries for a specific host by MAC address
// @Tags history
// @Produce json
// @Param mac path string true "MAC address of the host"
// @Param num query int true "Number of history entries to return"
// @Success 200 {array} models.Host
// @Router /history/{mac} [get]
func getHistoryByMAC(c *gin.Context) {
mac := c.Param("mac")
numStr := c.Query("num")
num, _ := strconv.Atoi(numStr)
hosts := gdb.SelectLatest(mac, num)
c.IndentedJSON(http.StatusOK, hosts)
}
// getHistoryByDate godoc
// @Summary Get history by date
// @Description Retrieve history for a specific host on a given date
// @Description The date format is flexible and can be:
// @Description - Year only: `2025`
// @Description - Year + month: `2025-09`
// @Description - Full date: `2025-09-06`
// @Description - Full timestamp: `2025-09-06 00:58:26`
// @Tags history
// @Produce json
// @Param mac path string true "MAC address of the host"
// @Param date path string true "Date filter (supports YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DD HH:mm:ss)"
// @Success 200 {array} models.Host
// @Router /history/{mac}/{date} [get]
func getHistoryByDate(c *gin.Context) {
mac := c.Param("mac")
date := c.Param("date")
hosts := gdb.SelectByDate(mac, date)
c.IndentedJSON(http.StatusOK, hosts)
}

View file

@ -0,0 +1,120 @@
package api
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/gdb"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// getAllHosts godoc
// @Summary Get all hosts
// @Description Retrieve all hosts from the database
// @Tags hosts
// @Produce json
// @Success 200 {array} models.Host
// @Router /all [get]
func getAllHosts(c *gin.Context) {
allHosts, _ := gdb.Select("now")
c.IndentedJSON(http.StatusOK, allHosts)
}
// getHost godoc
// @Summary Get host by ID
// @Description Retrieve detailed information about a host by its unique ID
// @Tags hosts
// @Produce json
// @Param id path string true "Host ID"
// @Success 200 {object} models.Host
// @Router /host/{id} [get]
func getHost(c *gin.Context) {
idStr := c.Param("id")
host := getHostByID(idStr) // functions.go
_, host.DNS = check.DNS(host)
c.IndentedJSON(http.StatusOK, host)
}
// delHost godoc
// @Summary Delete host
// @Description Remove a host from the database by its unique ID
// @Tags hosts
// @Produce json
// @Param id path string true "Host ID"
// @Success 200 {string} string "OK"
// @Router /host/del/{id} [get]
func delHost(c *gin.Context) {
idStr := c.Param("id")
host := getHostByID(idStr) // functions.go
gdb.Delete("now", host.ID)
slog.Info("Deleting from DB", "host", host)
c.IndentedJSON(http.StatusOK, "OK")
}
// addHost godoc
// @Summary Add host manually
// @Description Add host by MAC, with optional Name, IP, Hardware
// @Description Returns `models.Host` with this MAC form DB, either just added or existing
// @Tags hosts
// @Produce json
// @Param mac path string true "Host MAC"
// @Param name query string false "Name"
// @Param ip query string false "IP"
// @Param hw query string false "Hardware"
// @Success 200 {object} models.Host
// @Router /host/add/{mac} [get]
func addHost(c *gin.Context) {
mac := c.Param("mac")
hosts := gdb.SelectByMAC("now", mac)
if len(hosts) > 0 {
slog.Warn("Host with this MAC already exists", "host", hosts[0])
} else {
var host models.Host
host.Mac = mac
host.Name = c.Query("name")
host.IP = c.Query("ip")
host.Hw = c.Query("hw")
gdb.Update("now", host)
hosts = gdb.SelectByMAC("now", mac)
slog.Info("Added host to DB", "host", hosts[0])
}
c.IndentedJSON(http.StatusOK, hosts[0])
}
// editHost godoc
// @Summary Edit host
// @Description Update a host's name and optionally toggle its "known" status
// @Tags hosts
// @Produce json
// @Param id path string true "Host ID"
// @Param name path string true "New name for the host"
// @Param known path string false "Pass 'toggle' to flip the known/unknown status"
// @Success 200 {string} string "OK"
// @Router /edit/{id}/{name}/{known} [get]
func editHost(c *gin.Context) {
idStr := c.Param("id")
name := c.Param("name")
toggleKnown := c.Param("known")
host := getHostByID(idStr) // functions.go
host.Name = name
if toggleKnown == "/toggle" {
host.Known = 1 - host.Known
}
gdb.Update("now", host)
c.IndentedJSON(http.StatusOK, "OK")
}

View file

@ -0,0 +1,51 @@
package api
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/linde12/gowol"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/portscan"
)
// getPortState godoc
// @Summary Check port state
// @Description Check whether a given TCP port on an address is open or closed
// @Tags network
// @Produce json
// @Param addr path string true "IP address or hostname"
// @Param port path string true "Port number"
// @Success 200 {boolean} bool "true if open, false if closed"
// @Router /port/{addr}/{port} [get]
func getPortState(c *gin.Context) {
addr := c.Param("addr")
port := c.Param("port")
state := portscan.IsOpen(addr, port)
c.IndentedJSON(http.StatusOK, state)
}
// sendWOL godoc
// @Summary Send Wake-on-LAN packet
// @Description Send a magic packet to wake up a host by its MAC address
// @Tags network
// @Produce json
// @Param mac path string true "MAC address of the host"
// @Success 200 {boolean} bool "true if sent successfully"
// @Router /wol/{mac} [get]
func sendWOL(c *gin.Context) {
mac := c.Param("mac")
packet, err := gowol.NewMagicPacket(mac)
if !check.IfError(err) {
err = packet.Send("255.255.255.255")
slog.Info("Wake-on-LAN: " + mac)
}
c.IndentedJSON(http.StatusOK, !check.IfError(err))
}

View file

@ -0,0 +1,104 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/gdb"
"github.com/aceberg/WatchYourLAN/internal/models"
"github.com/aceberg/WatchYourLAN/internal/notify"
"github.com/aceberg/WatchYourLAN/internal/routines"
)
// getVersion godoc
// @Summary Get application version
// @Description Returns the current running version of the application
// @Tags system
// @Produce json
// @Success 200 {string} string
// @Router /version [get]
func getVersion(c *gin.Context) {
c.IndentedJSON(http.StatusOK, conf.AppConfig.Version)
}
// triggerRescan godoc
// @Summary Rescan all interfaces now
// @Description Manually trigger rescan
// @Tags system
// @Produce json
// @Success 200 {string} string "OK"
// @Router /rescan [get]
func triggerRescan(c *gin.Context) {
routines.ScanRestart()
c.Status(http.StatusOK)
}
// getConfig godoc
// @Summary Get application configuration
// @Description Returns the current configuration used by the app
// @Tags system
// @Produce json
// @Success 200 {object} models.Conf
// @Router /config [get]
func getConfig(c *gin.Context) {
c.IndentedJSON(http.StatusOK, conf.AppConfig)
}
// notifyTest godoc
// @Summary Send test notification
// @Description Trigger a test notification to verify notification settings
// @Tags system
// @Produce json
// @Success 200 {string} string "OK"
// @Router /notify_test [get]
func notifyTest(c *gin.Context) {
notify.Test()
c.Status(http.StatusOK)
}
// getStatus godoc
// @Summary Get network status
// @Description Retrieve summary statistics of hosts, optionally filtered by interface
// @Tags system
// @Produce json
// @Param iface path string false "Interface name (omit for all interfaces)"
// @Success 200 {object} models.Stat
// @Router /status/{iface} [get]
func getStatus(c *gin.Context) {
var status models.Stat
var searchHosts []models.Host
allHosts, _ := gdb.Select("now")
iface := c.Param("iface")
iface = iface[1:]
if iface != "" && iface != "undefined" {
for _, host := range allHosts {
if iface == host.Iface {
searchHosts = append(searchHosts, host)
}
}
} else {
searchHosts = allHosts
}
for _, host := range searchHosts {
status.Total = status.Total + 1
if host.Known > 0 {
status.Known = status.Known + 1
} else {
status.Unknown = status.Unknown + 1
}
if host.Now > 0 {
status.Online = status.Online + 1
} else {
status.Offline = status.Offline + 1
}
}
c.IndentedJSON(http.StatusOK, status)
}

View file

@ -0,0 +1,88 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/gdb"
"github.com/aceberg/WatchYourLAN/internal/routines"
)
func saveConfigHandler(c *gin.Context) {
conf.AppConfig.Host = c.PostForm("host")
conf.AppConfig.Port = c.PostForm("port")
conf.AppConfig.Theme = c.PostForm("theme")
conf.AppConfig.Color = c.PostForm("color")
conf.AppConfig.NodePath = c.PostForm("node")
conf.AppConfig.ShoutURL = c.PostForm("shout")
conf.Write(conf.AppConfig)
c.Redirect(http.StatusFound, c.Request.Referer())
}
func saveSettingsHandler(c *gin.Context) {
conf.AppConfig.LogLevel = c.PostForm("log")
conf.AppConfig.ArpArgs = c.PostForm("arpargs")
conf.AppConfig.Ifaces = c.PostForm("ifaces")
useDB := c.PostForm("usedb")
pgConnect := c.PostForm("pgconnect")
if useDB != conf.AppConfig.UseDB || pgConnect != conf.AppConfig.PGConnect {
conf.AppConfig.UseDB = c.PostForm("usedb")
conf.AppConfig.PGConnect = c.PostForm("pgconnect")
gdb.Connect()
}
timeout := c.PostForm("timeout")
trimHist := c.PostForm("trim")
conf.AppConfig.Timeout, _ = strconv.Atoi(timeout)
conf.AppConfig.TrimHist, _ = strconv.Atoi(trimHist)
arpStrs := c.PostFormArray("arpstrs")
conf.AppConfig.ArpStrs = []string{}
for _, s := range arpStrs {
if s != "" {
conf.AppConfig.ArpStrs = append(conf.AppConfig.ArpStrs, s)
}
}
conf.Write(conf.AppConfig)
routines.ScanRestart()
c.Redirect(http.StatusFound, c.Request.Referer())
}
func saveInfluxHandler(c *gin.Context) {
conf.AppConfig.InfluxAddr = c.PostForm("addr")
conf.AppConfig.InfluxToken = c.PostForm("token")
conf.AppConfig.InfluxOrg = c.PostForm("org")
conf.AppConfig.InfluxBucket = c.PostForm("bucket")
enable := c.PostForm("enable")
skip := c.PostForm("skip")
conf.AppConfig.InfluxEnable = enable == "on"
conf.AppConfig.InfluxSkipTLS = skip == "on"
conf.Write(conf.AppConfig)
c.Redirect(http.StatusFound, c.Request.Referer())
}
func savePrometheusHandler(c *gin.Context) {
enable := c.PostForm("enable")
conf.AppConfig.PrometheusEnable = enable == "on"
conf.Write(conf.AppConfig)
c.Redirect(http.StatusFound, c.Request.Referer())
}

View file

@ -0,0 +1,16 @@
package api
import (
"strconv"
"github.com/aceberg/WatchYourLAN/internal/gdb"
"github.com/aceberg/WatchYourLAN/internal/models"
)
func getHostByID(idStr string) (oneHost models.Host) {
id, _ := strconv.Atoi(idStr)
oneHost = gdb.SelectByID(id)
return oneHost
}

View file

@ -0,0 +1,41 @@
package api
import (
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// Routes - start API routes
func Routes(router *gin.Engine) {
r0 := router.Group("/api")
{
r0.GET("/all", getAllHosts) // api-hosts.go
r0.GET("/edit/:id/:name/*known", editHost) // api-hosts.go
r0.GET("/host/:id", getHost) // api-hosts.go
r0.GET("/host/del/:id", delHost) // api-hosts.go
r0.GET("/host/add/:mac", addHost) // api-hosts.go
r0.GET("/config", getConfig) // api-system.go
r0.GET("/notify_test", notifyTest) // api-system.go
r0.GET("/status/*iface", getStatus) // api-system.go
r0.GET("/version", getVersion) // api-system.go
r0.GET("/rescan", triggerRescan) // api-system.go
r0.GET("/history", getHistory) // api-history.go
r0.GET("/history/:mac", getHistoryByMAC) // api-history.go
r0.GET("/history/:mac/:date", getHistoryByDate) // api-history.go
r0.GET("/port/:addr/:port", getPortState) // api-network.go
r0.GET("/wol/:mac", sendWOL) // api-network.go
r0.POST("/config/", saveConfigHandler) // config.go
r0.POST("/config_settings/", saveSettingsHandler) // config.go
r0.POST("/config_influx/", saveInfluxHandler) // config.go
r0.POST("/config_prometheus/", savePrometheusHandler) // config.go
}
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}

View file

@ -29,12 +29,26 @@ func scanIface(iface string) string {
return string(out)
}
func scanStr(str string) string {
args := strings.Split(str, " ")
cmd := exec.Command("arp-scan", args...)
out, err := cmd.Output()
slog.Debug(cmd.String())
if check.IfError(err) {
return string("")
}
return string(out)
}
func parseOutput(text, iface string) []models.Host {
var foundHosts = []models.Host{}
perString := strings.Split(text, "\n")
p := strings.Split(text, "\n")
for _, host := range perString {
for _, host := range p {
if host != "" {
var oneHost models.Host
p := strings.Split(host, " ")
@ -52,19 +66,32 @@ func parseOutput(text, iface string) []models.Host {
}
// Scan all interfaces
func Scan(ifaces, args string) []models.Host {
func Scan(ifaces, args string, strs []string) []models.Host {
var text string
var p []string
var foundHosts = []models.Host{}
arpArgs = args
perString := strings.Split(ifaces, " ")
if ifaces != "" {
for _, iface := range perString {
slog.Debug("Scanning interface " + iface)
text = scanIface(iface)
p = strings.Split(ifaces, " ")
for _, iface := range p {
slog.Debug("Scanning interface " + iface)
text = scanIface(iface)
slog.Debug("Found IPs: \n" + text)
foundHosts = append(foundHosts, parseOutput(text, iface)...)
}
}
for _, s := range strs {
slog.Debug("Scanning string " + s)
text = scanStr(s)
slog.Debug("Found IPs: \n" + text)
p = strings.Split(s, " ")
foundHosts = append(foundHosts, parseOutput(text, iface)...)
foundHosts = append(foundHosts, parseOutput(text, p[len(p)-1])...)
}
return foundHosts

View file

@ -0,0 +1,21 @@
package check
import (
"net"
"strings"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// DNS - returns DNS names of a host
func DNS(host models.Host) (name, dns string) {
dnsNames, _ := net.LookupAddr(host.IP)
if len(dnsNames) > 0 {
name = dnsNames[0]
dns = strings.Join(dnsNames, " ")
}
return name, dns
}

View file

@ -1,14 +1,15 @@
package conf
import (
"strings"
"github.com/spf13/viper"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Get - get app config
func Get(path string) (config models.Conf) {
func read(path string) (config models.Conf) {
viper.SetDefault("HOST", "0.0.0.0")
viper.SetDefault("PORT", "8840")
@ -17,10 +18,10 @@ func Get(path string) (config models.Conf) {
viper.SetDefault("NODEPATH", "")
viper.SetDefault("LOG_LEVEL", "info")
viper.SetDefault("ARP_ARGS", "")
viper.SetDefault("ARP_STRS_JOINED", "")
viper.SetDefault("IFACES", "")
viper.SetDefault("TIMEOUT", 120)
viper.SetDefault("TRIM_HIST", 48)
viper.SetDefault("HIST_IN_DB", false)
viper.SetDefault("SHOUTRRR_URL", "")
viper.SetDefault("USE_DB", "sqlite")
@ -28,6 +29,8 @@ func Get(path string) (config models.Conf) {
viper.SetDefault("INFLUX_ENABLE", false)
viper.SetDefault("PROMETHEUS_ENABLE", false)
viper.SetConfigFile(path)
viper.SetConfigType("yaml")
err := viper.ReadInConfig()
@ -42,10 +45,10 @@ func Get(path string) (config models.Conf) {
config.NodePath = viper.Get("NODEPATH").(string)
config.LogLevel = viper.Get("LOG_LEVEL").(string)
config.ArpArgs = viper.Get("ARP_ARGS").(string)
config.ArpStrs = viper.GetStringSlice("ARP_STRS")
config.Ifaces = viper.Get("IFACES").(string)
config.Timeout = viper.GetInt("TIMEOUT")
config.TrimHist = viper.GetInt("TRIM_HIST")
config.HistInDB = viper.GetBool("HIST_IN_DB")
config.ShoutURL = viper.Get("SHOUTRRR_URL").(string)
config.UseDB = viper.Get("USE_DB").(string)
@ -58,38 +61,14 @@ func Get(path string) (config models.Conf) {
config.InfluxOrg, _ = viper.Get("INFLUX_ORG").(string)
config.InfluxBucket, _ = viper.Get("INFLUX_BUCKET").(string)
config.PrometheusEnable = viper.GetBool("PROMETHEUS_ENABLE")
joined := viper.Get("ARP_STRS_JOINED").(string)
// slog.Info("ARP_STRS_JOINED: " + joined)
if joined != "" {
config.ArpStrs = strings.Split(joined, ",")
}
return config
}
// Write - write config to file
func Write(config models.Conf) {
viper.SetConfigFile(config.ConfPath)
viper.SetConfigType("yaml")
viper.Set("HOST", config.Host)
viper.Set("PORT", config.Port)
viper.Set("THEME", config.Theme)
viper.Set("COLOR", config.Color)
viper.Set("NODEPATH", config.NodePath)
viper.Set("LOG_LEVEL", config.LogLevel)
viper.Set("ARP_ARGS", config.ArpArgs)
viper.Set("IFACES", config.Ifaces)
viper.Set("TIMEOUT", config.Timeout)
viper.Set("TRIM_HIST", config.TrimHist)
viper.Set("HIST_IN_DB", config.HistInDB)
viper.Set("SHOUTRRR_URL", config.ShoutURL)
viper.Set("USE_DB", config.UseDB)
viper.Set("PG_CONNECT", config.PGConnect)
viper.Set("influx_enable", config.InfluxEnable)
viper.Set("influx_skip_tls", config.InfluxSkipTLS)
viper.Set("influx_addr", config.InfluxAddr)
viper.Set("influx_token", config.InfluxToken)
viper.Set("influx_org", config.InfluxOrg)
viper.Set("influx_bucket", config.InfluxBucket)
err := viper.WriteConfig()
check.IfError(err)
}

View file

@ -0,0 +1,25 @@
package conf
import (
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// AppConfig - app config
var AppConfig models.Conf
// Start - initial config
func Start(dirPath, nodePath string) {
confPath := dirPath + "/config_v2.yaml"
check.Path(confPath)
AppConfig = read(confPath)
AppConfig.DirPath = dirPath
AppConfig.ConfPath = confPath
AppConfig.DBPath = dirPath + "/scan.db"
if nodePath != "" {
AppConfig.NodePath = nodePath
}
}

View file

@ -0,0 +1,48 @@
package conf
import (
"log/slog"
"github.com/spf13/viper"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Write - write config to file
func Write(config models.Conf) {
slog.Info("Writing new config to " + config.ConfPath)
viper.SetConfigFile(config.ConfPath)
viper.SetConfigType("yaml")
viper.Set("HOST", config.Host)
viper.Set("PORT", config.Port)
viper.Set("THEME", config.Theme)
viper.Set("COLOR", config.Color)
viper.Set("NODEPATH", config.NodePath)
viper.Set("LOG_LEVEL", config.LogLevel)
viper.Set("ARP_ARGS", config.ArpArgs)
viper.Set("ARP_STRS", config.ArpStrs)
viper.Set("ARP_STRS_JOINED", "") // Can be set only with ENV
viper.Set("IFACES", config.Ifaces)
viper.Set("TIMEOUT", config.Timeout)
viper.Set("TRIM_HIST", config.TrimHist)
viper.Set("SHOUTRRR_URL", config.ShoutURL)
viper.Set("USE_DB", config.UseDB)
viper.Set("PG_CONNECT", config.PGConnect)
viper.Set("influx_enable", config.InfluxEnable)
viper.Set("influx_skip_tls", config.InfluxSkipTLS)
viper.Set("influx_addr", config.InfluxAddr)
viper.Set("influx_token", config.InfluxToken)
viper.Set("influx_org", config.InfluxOrg)
viper.Set("influx_bucket", config.InfluxBucket)
viper.Set("PROMETHEUS_ENABLE", config.PrometheusEnable)
err := viper.WriteConfig()
check.IfError(err)
}

View file

@ -0,0 +1,40 @@
package gdb
import (
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Update - update or create host
func Update(table string, oneHost models.Host) {
tab := db.Table(table)
result := tab.Save(&oneHost)
check.IfError(result.Error)
}
// Delete - delete host from DB
func Delete(table string, id int) {
tab := db.Table(table)
result := tab.Delete(&models.Host{}, id)
check.IfError(result.Error)
}
// DeleteOldHistory - delete a list of hosts from History
func DeleteOldHistory(date string) int64 {
tab := db.Table("history")
result := tab.Where("\"DATE\" < ?", date).Delete(&models.Host{})
check.IfError(result.Error)
return result.RowsAffected
}
// Clear - delete all hosts from table
func Clear(table string) {
tab := db.Table(table)
result := tab.Where("1 = 1").Delete(&models.Host{})
check.IfError(result.Error)
}

View file

@ -0,0 +1,58 @@
package gdb
import (
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Select - get all hosts
func Select(table string) (dbHosts []models.Host, ok bool) {
tab := db.Table(table)
err := tab.Find(&dbHosts).Error
return dbHosts, !check.IfError(err)
}
// SelectByID - get host by ID
func SelectByID(id int) (host models.Host) {
tab := db.Table("now")
tab.First(&host, id)
return host
}
// SelectByMAC - get all hosts by MAC
func SelectByMAC(table, mac string) (hosts []models.Host) {
tab := db.Table(table)
tab.Where("\"MAC\" = ?", mac).Find(&hosts)
return hosts
}
// SelectByDate - get all hosts by MAC and DATE
func SelectByDate(mac, date string) (hosts []models.Host) {
tab := db.Table("history")
tab.
Where("\"MAC\" = ?", mac).
Where("\"DATE\" LIKE ?", date+"%").
Find(&hosts)
return hosts
}
// SelectLatest - get latest hosts by MAC
func SelectLatest(mac string, number int) (hosts []models.Host) {
tab := db.Table("history")
tab.
Where("\"MAC\" = ?", mac).
Order("\"DATE\" DESC").
Limit(number).
Find(&hosts)
return hosts
}

View file

@ -0,0 +1,86 @@
package gdb
import (
"log"
"log/slog"
"os"
"time"
sqlite "github.com/aceberg/gorm-sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/models"
)
var db *gorm.DB
var gormConf *gorm.Config
// Start working with DB
func Start() {
var tab *gorm.DB
var err error
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 5 * time.Second,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
gormConf = &gorm.Config{
Logger: newLogger,
NamingStrategy: schema.NamingStrategy{
NoLowerCase: true,
// So upper case Columns could work in both PostgreSQL and SQLite
},
}
Connect()
// Migrate the schema
tab = db.Table("now")
err = tab.AutoMigrate(&models.Host{})
check.IfError(err)
tab = db.Table("history")
err = tab.AutoMigrate(&models.Host{})
check.IfError(err)
}
// Connect - choose DB and connect
func Connect() {
var err error
var pgFail bool
if conf.AppConfig.UseDB == "postgres" {
db, err = gorm.Open(postgres.Open(conf.AppConfig.PGConnect), gormConf)
if err != nil {
pgFail = true
slog.Error("PostgreSQL connection error:", "err", err)
slog.Warn("Falling back to SQLite")
} else {
slog.Info("Connected to DB: PostgreSQL")
}
}
if pgFail || conf.AppConfig.UseDB != "postgres" {
db, err = gorm.Open(sqlite.Open(conf.AppConfig.DBPath), gormConf)
if !check.IfError(err) {
slog.Info("Connected to DB: SQLite")
db.Exec("PRAGMA journal_mode = wal;")
db.Exec("PRAGMA busy_timeout = 5000;")
}
}
}

View file

@ -0,0 +1,51 @@
package influx
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"strings"
"github.com/influxdata/influxdb-client-go/v2"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Add - write data to InfluxDB2
func Add(appConfig models.Conf, oneHist models.Host) {
var ctx context.Context
client := influxdb2.NewClientWithOptions(appConfig.InfluxAddr, appConfig.InfluxToken,
influxdb2.DefaultOptions().
SetUseGZip(true).
SetTLSConfig(&tls.Config{
InsecureSkipVerify: appConfig.InfluxSkipTLS,
}))
ctx = context.Background()
ping, err := client.Ping(ctx)
if ping {
writeAPI := client.WriteAPIBlocking(appConfig.InfluxOrg, appConfig.InfluxBucket)
// Escape special characters in strings
oneHist.Name = strings.ReplaceAll(oneHist.Name, " ", "\\ ")
oneHist.Name = strings.ReplaceAll(oneHist.Name, ",", "\\,")
oneHist.Name = strings.ReplaceAll(oneHist.Name, "=", "\\=")
if oneHist.Name == "" {
oneHist.Name = "unknown"
}
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)
// slog.Debug("Writing to InfluxDB", "line", line)
err = writeAPI.WriteRecord(context.Background(), line)
check.IfError(err)
} else {
slog.Error("Can't connect to InfluxDB server")
check.IfError(err)
}
client.Close()
}

View file

@ -0,0 +1,56 @@
package models
// Conf - app config
type Conf struct {
Host string
Port string
Theme string
Color string
DirPath string
ConfPath string
DBPath string
NodePath string
LogLevel string
Ifaces string
ArpArgs string
ArpStrs []string
Timeout int
TrimHist int
ShoutURL string
Version string
// PostgreSQL
UseDB string
PGConnect string
// InfluxDB
InfluxEnable bool
InfluxAddr string
InfluxToken string
InfluxOrg string
InfluxBucket string
InfluxSkipTLS bool
// Prometheus
PrometheusEnable bool
}
// Host - one host
type Host struct {
ID int `gorm:"column:ID;primaryKey"`
Name string `gorm:"column:NAME"`
DNS string `gorm:"column:DNS"`
Iface string `gorm:"column:IFACE"`
IP string `gorm:"column:IP"`
Mac string `gorm:"column:MAC"`
Hw string `gorm:"column:HW"`
Date string `gorm:"column:DATE"`
Known int `gorm:"column:KNOWN"`
Now int `gorm:"column:NOW"`
}
// Stat - status
type Stat struct {
Total int
Online int
Offline int
Known int
Unknown int
}

View file

@ -0,0 +1,43 @@
package notify
import (
"fmt"
"log/slog"
"os"
"github.com/nicholas-fedor/shoutrrr"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Unknown - send message to log and shoutrrr
func Unknown(host models.Host) {
msg := fmt.Sprintf("Unknown host found. Name: '%s', IP: '%s', MAC: '%s', Hw: '%s', Iface: '%s'", host.DNS, host.IP, host.Mac, host.Hw, host.Iface)
slog.Warn(msg)
shout(msg)
}
// Test Shoutrrr notification
func Test() {
msg := "test notification"
slog.Info("Sending " + msg)
shout(msg)
}
// shout - send msg to Shoutrrr
func shout(msg string) {
hostname, _ := os.Hostname()
wyl := "WatchYourLAN on '" + hostname + "': "
if conf.AppConfig.ShoutURL != "" {
err := shoutrrr.Send(conf.AppConfig.ShoutURL, wyl+msg)
if err != nil {
slog.Error("Notification failed (shoutrrr): ", "", err)
}
}
}

View file

@ -13,13 +13,12 @@ func IsOpen(host, port string) bool {
target := fmt.Sprintf("%s:%s", host, port)
conn, err := net.DialTimeout("tcp", target, timeout)
if err != nil {
return false
}
if conn != nil {
conn.Close()
return true
if err == nil {
err = conn.Close()
if err == nil {
return true
}
}
return false

View file

@ -0,0 +1,47 @@
package prometheus
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/models"
)
// Handler - display Prometheus metrics
func Handler() func(c *gin.Context) {
h := promhttp.Handler()
return func(c *gin.Context) {
if !conf.AppConfig.PrometheusEnable {
c.AbortWithStatus(http.StatusNotFound)
return
}
h.ServeHTTP(c.Writer, c.Request)
}
}
var up = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "watch_your_lan",
Name: "up",
Help: "Whether the host is up (1 for yes, 0 for no)",
}, []string{"ip", "iface", "name", "mac", "known"})
// Add a Prometheus metric
func Add(oneHist models.Host) {
if oneHist.Name == "" {
oneHist.Name = "unknown"
}
up.With(prometheus.Labels{
"ip": oneHist.IP,
"iface": oneHist.Iface,
"name": oneHist.Name,
"mac": oneHist.Mac,
"known": strconv.Itoa(oneHist.Known),
}).Set(float64(oneHist.Now))
}

View file

@ -1,23 +1,23 @@
package web
package routines
import (
"log/slog"
"github.com/aceberg/WatchYourLAN/internal/db"
"github.com/aceberg/WatchYourLAN/internal/conf"
)
func updateRoutines() {
slog.Debug("Restarting scan routine")
var (
quitScan = make(chan bool)
)
// ScanRestart - start or update routines
func ScanRestart() {
close(quitScan)
slog.Info("Restarting scan routine")
setLogLevel()
db.SetCurrent(appConfig)
db.Create()
allHosts = db.Select("now")
quitScan = make(chan bool)
go startScan(quitScan) // scan-routine.go
}
@ -25,22 +25,20 @@ func updateRoutines() {
func setLogLevel() {
var level slog.Level
switch appConfig.LogLevel {
slog.Info("Log level: " + conf.AppConfig.LogLevel)
switch conf.AppConfig.LogLevel {
case "debug":
slog.Info("Log level=DEBUG")
level = slog.LevelDebug
case "info":
slog.Info("Log level=INFO")
level = slog.LevelInfo
case "warn":
slog.Info("Log level=WARN")
level = slog.LevelWarn
case "error":
slog.Info("Log level=ERROR")
level = slog.LevelError
default:
slog.Error("Invalid log level. Setting default level INFO")
slog.SetLogLoggerLevel(slog.LevelInfo)
level = slog.LevelInfo
}
slog.SetLogLoggerLevel(level)
}

View file

@ -0,0 +1,91 @@
package routines
import (
"time"
"github.com/aceberg/WatchYourLAN/internal/arp"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/gdb"
"github.com/aceberg/WatchYourLAN/internal/influx"
"github.com/aceberg/WatchYourLAN/internal/models"
"github.com/aceberg/WatchYourLAN/internal/notify"
"github.com/aceberg/WatchYourLAN/internal/prometheus"
)
func startScan(quit chan bool) {
var lastDate, nowDate, plusDate time.Time
var foundHosts []models.Host
for {
select {
case <-quit:
return
default:
nowDate = time.Now()
plusDate = lastDate.Add(time.Duration(conf.AppConfig.Timeout) * time.Second)
if nowDate.After(plusDate) {
foundHosts = arp.Scan(conf.AppConfig.Ifaces, conf.AppConfig.ArpArgs, conf.AppConfig.ArpStrs)
// Make map of found hosts
foundHostsMap := make(map[string]models.Host)
for _, fHost := range foundHosts {
foundHostsMap[fHost.Mac] = fHost
}
compareHosts(foundHostsMap)
lastDate = time.Now()
}
time.Sleep(time.Duration(1) * time.Minute)
}
}
}
func compareHosts(foundHostsMap map[string]models.Host) {
allHosts, ok := gdb.Select("now")
if !ok {
return
}
for _, aHost := range allHosts {
fHost, exists := foundHostsMap[aHost.Mac]
if exists {
aHost.Iface = fHost.Iface
aHost.IP = fHost.IP
aHost.Date = fHost.Date
aHost.Now = 1
delete(foundHostsMap, aHost.Mac)
} else {
aHost.Now = 0
}
gdb.Update("now", aHost)
aHost.ID = 0
aHost.Date = time.Now().Format("2006-01-02 15:04:05")
gdb.Update("history", aHost)
if conf.AppConfig.InfluxEnable {
influx.Add(conf.AppConfig, aHost)
}
if conf.AppConfig.PrometheusEnable {
prometheus.Add(aHost)
}
}
for _, fHost := range foundHostsMap {
fHost.Name, fHost.DNS = check.DNS(fHost)
notify.Unknown(fHost) // Log and Shoutrrr
gdb.Update("now", fHost)
}
}

View file

@ -0,0 +1,28 @@
package routines
import (
"log/slog"
"time"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/gdb"
)
// HistoryTrim - routine for History
func HistoryTrim() {
go func() {
for {
time.Sleep(time.Duration(1) * time.Hour) // Every hour
hours := conf.AppConfig.TrimHist
nowMinus := time.Now().Add(-time.Duration(hours) * time.Hour)
date := nowMinus.Format("2006-01-02 15:04:05")
slog.Info("Removing all History before", "date", date)
n := gdb.DeleteOldHistory(date)
slog.Info("Removed records from History", "n", n)
}
}()
}

View file

@ -0,0 +1,12 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
func indexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", true)
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{t as m,k as n,l as c,h as I,m as $,n as N,p as S,q as E,i as t,e as i,r as M,F as U,b as O,s as _,S as P}from"./index.js";import{M as j}from"./MacHistory.js";var k=m('<input class=form-control placeholder="Show elements"title="Nomber of elements to show"style=max-width:10em;>');function q(l){const o=e=>{localStorage.setItem(l.name,e),n(+e),c()==0&&n(200)};return(()=>{var e=k();return e.$$input=r=>o(r.target.value),e})()}I(["input"]);var A=m('<div class="card border-primary"><div class="card-header d-flex justify-content-between"></div><div class=card-body><table class="table table-striped table-hover"><tbody></tbody>'),D=m("<tr><td class=opacity-50 style=width:2em;>.</td><td><a></a><br><a></a></td><td>");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};

View file

@ -0,0 +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('<div class="card border-primary"><div class=card-header>Host</div><div class="card-body table-responsive"><table class="table table-striped table-hover"><tbody><tr><td>ID</td><td></td></tr><tr><td>Name</td><td><input type=text class=form-control></td></tr><tr><td>DNS name</td><td></td></tr><tr><td>Iface</td><td></td></tr><tr><td>IP</td><td><a target=_blank></a></td></tr><tr><td>MAC</td><td></td></tr><tr><td>Hardware</td><td></td></tr><tr><td>Date</td><td></td></tr><tr><td>Known</td><td><div class="form-check form-switch"><input class=form-check-input type=checkbox></div></td></tr><tr><td>Online</td><td>&nbsp;&nbsp;&nbsp;<button type=button class="btn btn-outline-success">Wake-on-LAN</button></td></tr></tbody></table><button type=button class="btn btn-outline-danger">Delete host'),_t=u('<i class="bi bi-check-circle-fill"style=color:var(--bs-success);>'),mt=u('<i class="bi bi-circle-fill"style=color:var(--bs-gray-500);>');function vt(t){let l="";const i=rt(async a=>{await B(t.host.ID,a,"")},300),n=async a=>{l=a,i(a)},d=async()=>{l==""&&(l=t.host.Name),await B(t.host.ID,l,"toggle")},_=async()=>{await dt(t.host.ID),window.location.href="/"},h=async()=>{await st(t.host.Mac)};return(()=>{var a=gt(),f=a.firstChild,v=f.nextSibling,o=v.firstChild,D=o.firstChild,r=D.firstChild,$=r.firstChild,g=$.nextSibling,s=r.nextSibling,y=s.firstChild,w=y.nextSibling,k=w.firstChild,S=s.nextSibling,c=S.firstChild,b=c.nextSibling,x=S.nextSibling,P=x.firstChild,G=P.nextSibling,M=x.nextSibling,R=M.firstChild,q=R.nextSibling,A=q.firstChild,L=M.nextSibling,J=L.firstChild,Q=J.nextSibling,E=L.nextSibling,U=E.firstChild,V=U.nextSibling,O=E.nextSibling,X=O.firstChild,Y=X.nextSibling,F=O.nextSibling,Z=F.firstChild,tt=Z.nextSibling,et=tt.firstChild,T=et.firstChild,lt=F.nextSibling,it=lt.firstChild,W=it.nextSibling,j=W.firstChild,nt=j.nextSibling,at=o.nextSibling;return e(g,()=>t.host.ID),k.$$input=H=>n(H.target.value),e(b,()=>t.host.DNS),e(G,()=>t.host.Iface),e(A,()=>t.host.IP),e(Q,()=>t.host.Mac),e(V,()=>t.host.Hw),e(Y,()=>t.host.Date),T.$$click=d,e(W,(()=>{var H=I(()=>t.host.Now==1);return()=>H()?_t():mt()})(),j),nt.$$click=h,at.$$click=_,p(()=>K(A,"href","http://"+t.host.IP)),p(()=>k.value=t.host.Name),p(()=>T.checked=t.host.Known==1),a})()}N(["input","click"]);var St=u('<div class="card border-primary"><div class=card-header>Port Scan</div><div class=card-body><form class=input-group><input type=text class=form-control placeholder=1><input type=text class=form-control placeholder=65535><button type=button class="btn btn-primary">Scan</button></form><div class=mt-2>'),xt=u('<div class="d-flex justify-content-between mt-2"><button type=button class="btn btn-warning">Stop/Continue</button><div>Scanning port: '),pt=u("<a class=me-4 target=_blank>");function Ct(t){let l=!1;const[i,n]=m(""),[d,_]=m(""),[h,a]=m(""),[f,v]=m([]),o=async()=>{l=!1;let r=Number(i());(Number.isNaN(r)||r<1||r>65535)&&(r=1);let $=Number(d());(Number.isNaN($)||$<1||$>65535)&&($=65535);let g;for(let s=r;s<=$&&!l;s++)a(s.toString()),g=await ot(t.IP,s),g&&v([...f(),s])},D=()=>{l?(n(h()),o()):l=!0};return(()=>{var r=St(),$=r.firstChild,g=$.nextSibling,s=g.firstChild,y=s.firstChild,w=y.nextSibling,k=w.nextSibling,S=s.nextSibling;return y.$$input=c=>n(c.target.value),w.$$input=c=>_(c.target.value),k.$$click=o,e(g,(()=>{var c=I(()=>h()!="");return()=>c()?(()=>{var b=xt(),x=b.firstChild,P=x.nextSibling;return P.firstChild,x.$$click=D,e(P,h,null),b})():[]})(),S),e(S,C(ct,{get each(){return f()},children:c=>(()=>{var b=pt();return e(b,c),p(()=>K(b,"href","http://"+t.IP+":"+c)),b})()})),r})()}N(["input","click"]);var yt=u('<div class="card border-primary"><div class=card-header><div class=input-group style=width:fit-content;><span class=input-group-text>Host History for</span><input type=date class=form-control></div></div><div class=card-body>');function wt(t){const[l,i]=m("");z(()=>{$t(15e3),i(new Date().toLocaleDateString("en-CA"))});const n=d=>{i(""),i(d)};return(()=>{var d=yt(),_=d.firstChild,h=_.firstChild,a=h.firstChild,f=a.nextSibling,v=_.nextSibling;return f.$$input=o=>n(o.currentTarget.value),e(v,(()=>{var o=I(()=>t.mac!==""&&l()!=="");return()=>o()?C(ft,{get mac(){return t.mac},get date(){return l()}}):"Loading..."})()),p(()=>f.value=l()),d})()}N(["input"]);var kt=u("<div class=row><div class=col-md></div><div class=col-md>"),Pt=u('<div class="row mt-4"><div class=col-md>');function It(){const[t,l]=m(ut);return z(async()=>{const i=bt(),n=await ht(i.id);l(n)}),[(()=>{var i=kt(),n=i.firstChild,d=n.nextSibling;return e(n,C(vt,{get host(){return t()}})),e(d,C(Ct,{get IP(){return t().IP}})),i})(),(()=>{var i=Pt(),n=i.firstChild;return e(n,C(wt,{get mac(){return t().Mac}})),i})()]}export{It as default};

View file

@ -0,0 +1,4 @@
import{C as y,D as f,E as v,o as m,G as H,e as u,S as d,t as D,b as I,s as b,H as h,l as M,F as g}from"./index.js";async function w(a,r){let e=[];return r===""?e=await y(a):e=await f(a,r),e!=null?(e.sort((s,t)=>s.Date<t.Date?1:-1),e):[]}var x=D("<i>");function F(a){const[r,e]=v([]);let s;return m(async()=>{const t=await w(a.mac,a.date);e(t),s=setInterval(async()=>{const o=await w(a.mac,a.date);e(o)},6e4)}),H(()=>{clearInterval(s)}),u(g,{each:r,children:(t,o)=>u(d,{get when(){return o()<M()},get children(){var i=x();return I(n=>{var c="Date:"+t.Date+`
Iface:`+t.Iface+`
IP:`+t.IP+`
Known:`+t.Known,l=t.Now===0?"my-box-off":"my-box-on";return c!==n.e&&b(i,"title",n.e=c),l!==n.t&&h(i,n.t=l),n},{e:void 0,t:void 0}),i}})})}export{F as M};

View file

@ -0,0 +1 @@
:root{--transparent-light: #ffffff15}.my-btn{height:100%;background-color:#0000;color:var(--bs-primary);text-align:center;cursor:pointer;padding:1px;border-radius:15%}.my-btn:hover{background-color:var(--transparent-light)}.my-box-on:before,.my-box-off:before{content:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='18'></svg>");border-left:thin solid black}.my-box-on{background-color:var(--bs-success)}.my-box-off{background-color:var(--bs-gray-500)}.my-box-on:hover,.my-box-off:hover{background-color:#0000001a}

File diff suppressed because one or more lines are too long

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1 @@
VERSION=2.1.4

View file

@ -0,0 +1,19 @@
{{ define "index.html" }}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<title>WatchYourLAN</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--Favicon-->
<link rel="icon" type="image/x-icon" href="/fs/public/favicon.png">
<!-- JS & CSS -->
<script type="module" crossorigin src="/fs/public/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/fs/public/assets/index.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
{{ end }}

View file

@ -0,0 +1,66 @@
package web
import (
"embed"
"html/template"
"log/slog"
"net/http"
"github.com/aceberg/WatchYourLAN/internal/api"
"github.com/aceberg/WatchYourLAN/internal/check"
"github.com/aceberg/WatchYourLAN/internal/conf"
"github.com/aceberg/WatchYourLAN/internal/prometheus"
"github.com/gin-gonic/gin"
)
// templFS - html templates
//
//go:embed templates/*
var templFS embed.FS
// pubFS - public folder
//
//go:embed public/*
var pubFS embed.FS
// Gui - start web server
func Gui() {
const (
colorCyan = "\033[36m"
colorReset = "\033[0m"
)
file, err := pubFS.ReadFile("public/version")
check.IfError(err)
conf.AppConfig.Version = string(file)[8:]
address := conf.AppConfig.Host + ":" + conf.AppConfig.Port
slog.Info(colorCyan + "\n=================================== " +
"\n WatchYourLAN Version: " + conf.AppConfig.Version +
"\n Config dir: " + conf.AppConfig.DirPath +
"\n Default DB: " + conf.AppConfig.UseDB +
"\n Log level: " + conf.AppConfig.LogLevel +
"\n Web GUI: http://" + address +
"\n=================================== " + colorReset)
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
templ := template.Must(template.New("").ParseFS(templFS, "templates/*"))
router.SetHTMLTemplate(templ) // templates
router.StaticFS("/fs/", http.FS(pubFS)) // public
router.GET("/", indexHandler) // index.go
router.GET("/config", indexHandler) // index.go
router.GET("/history", indexHandler) // index.go
router.GET("/host/*any", indexHandler) // index.go
router.GET("/metrics", prometheus.Handler())
api.Routes(router)
err = router.Run(address)
check.IfError(err)
}

View file

@ -1,31 +0,0 @@
package main
import (
"flag"
// "net/http"
// _ "net/http/pprof"
_ "time/tzdata"
"github.com/aceberg/WatchYourLAN/internal/web"
)
const dirPath = "/data/WatchYourLAN"
const nodePath = ""
func main() {
dirPtr := flag.String("d", dirPath, "Path to config dir")
nodePtr := flag.String("n", nodePath, "Path to node modules")
flag.Parse()
// pprof - memory leak detect
// go tool pprof -alloc_space http://localhost:8085/debug/pprof/heap
// (pprof) web
// (pprof) list db.Select
//
// go func() {
// http.ListenAndServe("localhost:8085", nil)
// }()
web.Gui(*dirPtr, *nodePtr) // webgui.go
}

37
docker-compose-auth.yml Normal file
View file

@ -0,0 +1,37 @@
version: "3"
services:
wyl:
image: aceberg/watchyourlan
network_mode: "host"
restart: unless-stopped
volumes:
- ~/.dockerdata/wyl:/data/WatchYourLAN
environment:
TZ: Asia/Novosibirsk # required: needs your TZ for correct time
IFACES: "enp4s0 wlxf4ec3892dd51" # required: 1 or more interface
HOST: "0.0.0.0" # optional, default: 0.0.0.0
PORT: "8840" # optional, default: 8840
TIMEOUT: "120" # optional, time in seconds, default: 120
SHOUTRRR_URL: "" # optional, set url to notify
THEME: "sand" # optional
COLOR: "dark" # optional
# 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
forauth:
image: aceberg/forauth
restart: unless-stopped
ports:
- 8800:8800 # Proxy port
- 8801:8801 # Config port
volumes:
- ~/.dockerdata/forauth:/data/ForAuth
environment:
TZ: Asia/Novosibirsk # required: needs your TZ for correct time
FA_TARGET: "YOUR_IP:8840" # optional: path to wyl host:port
FA_AUTH: "true" # optional: true - enabled, default: false
FA_AUTH_EXPIRE: 7d # optional: expiration time, default: 7d
FA_AUTH_PASSWORD: "$$2a$$10$$wGLUHXh2cRN1257uGg1s5eZvYgnjw8wB9vAcfcHqqqrxm5hvBqAzK"
# WARNING! If password is set as environment variable, every '$' character must be escaped with another '$', like this '$$'
# optional: password encrypted with bcrypt, how-to: https://github.com/aceberg/ForAuth/blob/main/docs/BCRYPT.md (In this example FA_AUTH_PASSWORD=pw)
FA_AUTH_USER: user # optional: username

View file

@ -1,25 +0,0 @@
version: "3"
services:
node-bootstrap:
image: aceberg/node-bootstrap
restart: unless-stopped
ports:
- 8850:8850
wyl:
image: aceberg/watchyourlan:v2
network_mode: "host"
restart: unless-stopped
command: "-n http://YOUR_IP:8850" # put your server IP or DNS name here
depends_on:
- node-bootstrap
volumes:
- ~/.dockerdata/wyl:/data/WatchYourLAN
environment:
TZ: Asia/Novosibirsk # required: needs your TZ for correct time
IFACES: "enp4s0 wlxf4ec3892dd51" # required: 1 or more interface
HOST: "0.0.0.0" # optional, default: 0.0.0.0
PORT: "8840" # optional, default: 8840
TIMEOUT: "120" # optional, time in seconds, default: 120
SHOUTRRR_URL: "" # optional, set url to notify
THEME: "sand" # optional
COLOR: "dark" # optional

View file

@ -1,17 +1,28 @@
version: "3"
services:
# node-bootstrap: # optional, local themes and icons
# image: aceberg/node-bootstrap # dockerhub
# # image: ghcr.io/aceberg/node-bootstrap # or github
# restart: unless-stopped
# ports:
# - 8850:8850
wyl:
image: aceberg/watchyourlan:v2
network_mode: "host"
image: aceberg/watchyourlan # dockerhub
# image: ghcr.io/aceberg/watchyourlan # or github
network_mode: "host"
restart: unless-stopped
# uncomment those if you are using local node-bootstrap:
# command: "-n http://YOUR_IP:8850" # put your server IP or DNS name here
# depends_on:
# - node-bootstrap
volumes:
- ~/.dockerdata/wyl:/data/WatchYourLAN
environment:
TZ: Asia/Novosibirsk # required: needs your TZ for correct time
IFACES: "enp4s0 wlxf4ec3892dd51" # required: 1 or more interface
HOST: "0.0.0.0" # optional, default: 0.0.0.0
PORT: "8840" # optional, default: 8840
TIMEOUT: "120" # optional, time in seconds, default: 120
SHOUTRRR_URL: "" # optional, set url to notify
THEME: "sand" # optional
COLOR: "dark" # optional
# HOST: "0.0.0.0" # optional, default: 0.0.0.0
# PORT: "8840" # optional, default: 8840
# TIMEOUT: "120" # optional, time in seconds, default: 120
# SHOUTRRR_URL: "" # optional, set url to notify
# THEME: "sand" # optional
# COLOR: "dark" # optional

View file

@ -6,9 +6,19 @@ Returns all hosts in `json`.
```http
GET /api/history/*mac
GET /api/history
```
Returns all History. If `mac` is not empty, returns only history of a device with this `mac`.
Returns all History. Not recommended, the output can be a lot.
```http
GET /api/history/:mac/:date
```
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`.
```http
GET /api/history/:mac?num=20
```
Returns only last 20 lines of history of a device with this `mac`.
```http
@ -39,4 +49,16 @@ Edit host with ID `id`. Can change `name`. `known` is optional, when set to `tog
```http
GET /api/host/del/:id
```
Remove host with ID `id`.
Remove host with ID `id`.
```http
GET /api/notify_test
```
Send test notification.
```http
GET /api/status/*iface
```
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/`.

66
docs/VLAN_ARP_SCAN.md Normal file
View file

@ -0,0 +1,66 @@
## Passing arguments to arp-scan
### 1. IFACES
`IFACES` is a required variable for WYL to work. It can be set through `GUI`, config file or environment variables.
`IFACES` is a list of network interfaces to scan, space separated. For example
```sh
IFACES: "enp4s0 wlxf4ec3892dd51"
```
You can get a list of network interfaces by running `ip link show` or `netstat -i`.
By default, the scan command will look like this:
```sh
arp-scan -glNx -I $ONE_IFACE
```
### 2. ARP_ARGS
Setting `ARP_ARGS` is optional. It can be set through `GUI`, config file or environment variables.
`ARP_ARGS` is additional arguments for `arp-scan`, that will be applied for **every** one of `IFACES`. For example:
```sh
ARP_ARGS: "-r 1"
```
```sh
arp-scan -glNx -r 1 -I $ONE_IFACE
```
See `man arp-scan` for all arguments available.
### 3. ARP_STRS for VLANs, docker0 and other complicated scans
If `ARP_STRS` is set, it will initiate a completely separate from `IFACES` scan.
> [!WARNING]
> `ARP_STRS` can be set only through `GUI` or config file. For environment (docker-compose) see `ARP_STRS_JOINED`.
`ARP_STRS` is a list of strings. `arp-scan` will run for each of them:
```sh
arp-scan $ONE_STRING
```
Every string must contain all information you need to pass to `arp-scan`. For example:
```sh
arp-scan -gNx 10.0.107.0/24 -Q 107 -I eth0
```
Where `-Q` is a `vlan` id. **Warning:** the last element of string (`eth0` in this example) will be set as `Interface` for found hosts, so it is recommended to put interface at the end.
Setting `ARP_STRS` from config file:
```yaml
arp_strs:
- -gNx 172.17.0.1/24 -I docker0
- -glNx -I virbr0
```
From `GUI` put one string in `Arp Strings` input field, click `Save`, then another empty string will appear.
### 4. ARP_STRS_JOINED
`ARP_STRS_JOINED` is a way to set `ARP_STRS` from ENV. It's a list of strings, comma separated, without spaces before or after comma.
```sh
ARP_STRS_JOINED: "-gNx 172.17.0.1/24 -I docker0,-gNx 10.0.107.0/24 -Q 107 -I eth0"
```
### 5. Examples
vlan id 107
```sh
ARP_STRS_JOINED: "-gNx 10.0.107.0/24 -Q 107 -I eth0"
```
docker0
```sh
ARP_STRS_JOINED: "-gNx 172.17.0.1/24 -I docker0"
```

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
frontend/Makefile Normal file
View file

@ -0,0 +1,14 @@
PKG_NAME=WatchYourLAN
USR_NAME=aceberg
build:
npm run build && \
rm ../backend/internal/web/public/assets/* && \
cp -r dist/assets ../backend/internal/web/public
replace:
cd ../backend/internal/web/public/assets/ && \
cat index.js | sed 's/assets/fs\/public\/assets/g;s/http:\/\/0.0.0.0:8840//' > tmp && \
mv tmp index.js
all: build replace

28
frontend/README.md Normal file
View file

@ -0,0 +1,28 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WatchYourLAN</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

1664
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
frontend/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "watchyourlan",
"private": true,
"version": "2.1.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@solid-primitives/scheduled": "^1.5.2",
"@solidjs/router": "^0.15.3",
"solid-js": "^1.9.5"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vite-plugin-solid": "^2.11.2"
}
}

View file

@ -1,17 +1,25 @@
/* VARS */
:root {
--transparent-light: #ffffff15;
}
/* Sort button */
.my-btn {
height: 100%;
background-color: #00000000;
color: var(--bs-primary);
text-align: center;
cursor: pointer;
padding: 1px;
border-radius: 15%;
}
.my-btn:hover {
background-color: #0000001a;
background-color: var(--transparent-light);
}
/* History box */
.my-box-on::before, .my-box-off::before {
content: url(/fs/public/box.svg);
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='18'></svg>");
border-left: thin solid black;
}
.my-box-on {
@ -22,9 +30,4 @@
}
.my-box-on:hover, .my-box-off:hover {
background-color: #0000001a;
}
.hidden {
display: none;
}

38
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,38 @@
import { lazy, onMount } from 'solid-js';
import { Router, Route } from "@solidjs/router";
import './App.css';
import { runAtStart } from './functions/atstart';
import Body from './pages/Body';
import Header from './components/Header';
function App() {
onMount(() => {
runAtStart();
});
const Config = lazy(() => import("./pages/Config"));
const History = lazy(() => import("./pages/History"));
const HostPage = lazy(() => import("./pages/HostPage"));
return (
<>
<Header></Header>
<div class="container-lg">
<div class="row">
<div class="col-md mt-4 mb-4">
<Router>
<Route path="/" component={Body}/>
<Route path="/config" component={Config}/>
<Route path="/history" component={History}/>
<Route path="/host/:id" component={HostPage}/>
</Router>
</div>
</div>
</div>
</>
)
}
export default App

View file

@ -0,0 +1,50 @@
import { Show } from "solid-js";
import { editNames, selectedIDs, setEditNames } from "../../functions/exports";
import Filter from "../Filter";
import Search from "../Search";
import { getHosts } from "../../functions/atstart";
import { apiDelHost } from "../../functions/api";
function CardHead() {
const handleEditNames = (toggle: boolean) => {
if (!toggle) {
getHosts();
}
setEditNames(toggle);
};
const handleDel = async () => {
const ids = selectedIDs();
for (let id of ids) {
await apiDelHost(id);
}
window.location.href = '/';
};
return (
<div class="row">
<div class="col-md mt-1 mb-1">
<div class="d-flex justify-left">
<Filter></Filter>
</div>
</div>
<div class="col-md mt-1 mb-1">
<div class="d-flex justify-content-between">
<Search></Search>
<Show
when={editNames()}
fallback={<button class="btn btn-outline-primary" title="Toggle edit" onClick={[handleEditNames, true]}>Edit</button>}
>
<button type="button" onClick={handleDel} title="Delete selected hosts" class="btn btn-outline-danger">Delete selected</button>
<button class="btn btn-primary" title="Toggle edit" onClick={[handleEditNames, false]}>Edit</button>
</Show>
</div>
</div>
</div>
)
}
export default CardHead

View file

@ -0,0 +1,45 @@
import { createSignal, For } from "solid-js";
import { Host } from "../../functions/exports";
import { sortByAnyField } from "../../functions/sort";
function TableHead() {
const [sortField, setSortField] = createSignal<string>('');
const showSort = () => {
let field = localStorage.getItem("sortField") as string;
field === "Mac" ? field = "MAC" : '';
field === "Hw" ? field = "Hardware" : '';
field === "Now" ? field = "On" : '';
setSortField(field);
};
showSort();
const handleSort = (sortBy: string) => {
setSortField(sortBy);
sortBy === "MAC" ? sortBy = "Mac" : '';
sortBy === "Hardware" ? sortBy = "Hw" : '';
sortBy === "On" ? sortBy = "Now" : '';
sortByAnyField(sortBy as keyof Host);
};
return (
<thead>
<tr>
<th style="width: 2em;"></th>
<For each={["Name", "Iface", "IP", "MAC", "Hardware", "Date", "Known", "On"]}>{(key) =>
<th
style={key === sortField() ? "color: var(--bs-primary);" : ''}
>{key} <i
class="bi bi-sort-down-alt my-btn"
onClick={[handleSort, key]}
title={"Sort by " + key}
></i></th>
}</For>
<th style="width: 2em;" title="Edit"><i class="bi bi-pencil-fill"></i></th>
</tr>
</thead>
)
}
export default TableHead

View file

@ -0,0 +1,86 @@
import { createSignal, Show } from "solid-js";
import { editNames, selectedIDs, setSelectedIDs } from "../../functions/exports";
import { apiEditHost } from "../../functions/api";
import { debounce } from "@solid-primitives/scheduled";
function TableRow(_props: any) {
const [name, setName] = createSignal(_props.host.Name);
let now = <i class="bi bi-circle-fill" style="color:var(--bs-gray-500);"></i>;
if (_props.host.Now == 1) {
now = <i class="bi bi-check-circle-fill" style="color:var(--bs-success);"></i>;
};
let known:boolean;
_props.host.Known === 1 ? known = true : known = false;
const debouncedApi = debounce(async (val: string) => {
await apiEditHost(_props.host.ID, val, "");
}, 300);
const handleInput = async (n: string) => {
setName(n);
debouncedApi(n);
};
const handleToggle = async () => {
await apiEditHost(_props.host.ID, name(), "toggle");
};
const handleCheck = (checked: boolean) => {
const id = _props.host.ID;
setSelectedIDs(prev => {
if (checked) {
return prev.includes(id) ? prev : [...prev, id];
} else {
return prev.filter(item => item !== id);
}
});
};
return (
<tr>
<td class="opacity-50">{_props.index}.</td>
<td>
<Show
when={editNames()}
fallback={name()}
>
<input type="text" class="form-control" value={name()}
onInput={e => handleInput(e.target.value)}></input>
</Show>
</td>
<td>{_props.host.Iface}</td>
<td><a href={"http://" + _props.host.IP} target="_blank">{_props.host.IP}</a></td>
<td>{_props.host.Mac}</td>
<td title={_props.host.Hw}>{_props.host.Hw.slice(0,12)+".."}</td>
<td>{_props.host.Date}</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" checked={known}
onClick={handleToggle}></input>
</div>
</td>
<td>{now}</td>
<td>
<Show
when={editNames()}
fallback={
<a href={"/host/" + _props.host.ID}>
<i class="bi bi-three-dots-vertical my-btn p-2" title="More"></i>
</a>}
>
<input
type="checkbox"
class="form-check-input"
checked={selectedIDs().includes(_props.host.ID)}
onChange={e => handleCheck((e.target as HTMLInputElement).checked)}
/>
</Show>
</td>
</tr>
)
}
export default TableRow

View file

@ -0,0 +1,64 @@
import { createSignal, onMount } from "solid-js";
import { apiGetVersion } from "../../functions/api"
function About() {
const [version, setVersion] = createSignal('');
const [link, setLink] = createSignal('');
onMount(async () => {
const v = await apiGetVersion();
setVersion(v);
setLink("https://github.com/aceberg/WatchYourLAN/releases/tag/"+v);
});
return (
<div class="card border-primary">
<div class="card-header">
About (<a href={link()} target="_blank">{version()}</a>)
</div>
<div class="card-body table-responsive">
<table class="table table-striped"><tbody>
<tr>
<td><b>Swagger API docs</b></td>
<td><a href="/swagger/index.html" target="_blank">/swagger/index.html</a></td>
</tr>
<tr>
<td><b>Local node-bootstrap URL</b></td>
<td>local themes and fonts (optional). If empty, the app will pull everything from <code>cdn</code></td>
</tr>
<tr>
<td><b>Shoutrrr URL</b></td>
<td>provides notifications to Discord, Email, Gotify, Telegram and other services. <a href="https://shoutrrr.nickfedor.com/services/overview/" target="_blank">Link to documentation</a></td>
</tr>
<tr>
<td><b>Interfaces</b></td>
<td>one or more, space separated</td>
</tr>
<tr>
<td><b>Timeout (seconds)</b></td>
<td>time between scans</td>
</tr>
<tr>
<td><b>Args for arp-scan</b></td>
<td>pass your own arguments to <code>arp-scan</code>. Enable <b>debug</b> log level to see resulting command. (Example: <code>-r 1</code>). See <a href="https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md" target="_blank">docs</a> for more</td>
</tr>
<tr>
<td><b>Arp Strings</b></td>
<td>can setup scans for <code>vlans</code>, <code>docker0</code> and etcetera. See <a href="https://github.com/aceberg/WatchYourLAN/blob/main/docs/VLAN_ARP_SCAN.md" target="_blank">docs</a> for more</td>
</tr>
<tr>
<td><b>Trim History</b></td>
<td>remove history after (hours)</td>
</tr>
<tr>
<td><b>PG Connect URL</b></td>
<td>address to connect to PostgreSQL DB. (Example: <code>postgres://username:password@192.168.0.1:5432/dbname?sslmode=disable</code>). Full list of URL parameters <a href="https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters" target="_blank">here</a></td>
</tr>
</tbody></table>
</div>
</div>
)
}
export default About

View file

@ -0,0 +1,83 @@
import { For, Show } from "solid-js";
import { apiPath, apiTestNotify } from "../../functions/api"
import { appConfig } from "../../functions/exports"
function Basic() {
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"];
const handleTestNotify = () => {
apiTestNotify();
};
return (
<div class="card border-primary">
<div class="card-header">Basic config</div>
<div class="card-body table-responsive">
<form action={apiPath + '/api/config/'} method="post">
<table class="table table-borderless">
<tbody>
<tr>
<td>Host</td>
<td><input name="host" type="text" class="form-control" value={appConfig().Host}></input></td>
</tr>
<tr>
<td>Port</td>
<td><input name="port" type="text" class="form-control" value={appConfig().Port}></input></td>
</tr>
<tr>
<td>Theme</td>
<td>
<select name="theme" class="form-select">
<For each={themes}>{theme =>
<Show
when={theme == appConfig().Theme}
fallback={<option value={theme}>{theme}</option>}
>
<option value={theme} selected>{theme}</option>
</Show>
}</For>
</select>
</td>
</tr>
<tr>
<td>Color mode</td>
<td>
<select name="color" class="form-select">
<Show
when={appConfig().Color == "dark"}
fallback={<>
<option value="dark">dark</option>
<option value="light" selected>light</option>
</>}
>
<option value="dark" selected>dark</option>
<option value="light">light</option>
</Show>
</select>
</td>
</tr>
<tr>
<td>Local node-bootstrap URL</td>
<td><input name="node" type="text" class="form-control" value={appConfig().NodePath}></input></td>
</tr>
<tr>
<td>Shoutrrr URL</td>
<td>
<textarea name="shout" class="form-control" style="width: 100%;" rows="3" wrap="soft">{appConfig().ShoutURL}</textarea>
</td>
</tr>
<tr>
<td><button type="submit" class="btn btn-primary">Save</button></td>
<td><button onClick={handleTestNotify} type="button" style="float: right;" class="btn btn-info">Test notification</button></td>
<td></td>
</tr>
</tbody>
</table>
</form>
</div>
</div>
)
}
export default Basic

View file

@ -0,0 +1,17 @@
function Donate() {
return (
<div class="card border-info">
<div class="card-header">Donations and Work</div>
<div class="card-body">
<p>Every <a href="https://github.com/aceberg#donate" target="_blank">donation</a> is truly appreciated!</p>
<p>I am offering self-hosted and mobile apps development for a small price.
The Open Source apps I created are listed in my <a href="https://aceberg.github.io/portfolio/" target="_blank">portfolio</a>. They are all human-coded, not generated by AI. Please, <a href="https://github.com/aceberg#contact" target="_blank">contact</a> me, if you are interested.</p>
</div>
</div>
)
}
export default Donate

View file

@ -0,0 +1,61 @@
import { apiPath } from "../../functions/api"
import { appConfig } from "../../functions/exports"
function Influx() {
return (
<div class="card border-primary">
<div class="card-header">InfluxDB2 config</div>
<div class="card-body table-responsive">
<form action={apiPath + '/api/config_influx/'} method="post">
<table class="table table-borderless"><tbody>
<tr>
<td>Enable</td>
<td>
<div class="form-check form-switch">
{appConfig().InfluxEnable
? <input class="form-check-input" type="checkbox" name="enable" checked></input>
: <input class="form-check-input" type="checkbox" name="enable"></input>
}
</div>
</td>
</tr>
<tr>
<td>Address</td>
<td><input name="addr" type="text" class="form-control" value={appConfig().InfluxAddr}></input></td>
</tr>
<tr>
<td>Token</td>
<td><input name="token" type="text" class="form-control" value={appConfig().InfluxToken}></input></td>
</tr>
<tr>
<td>Org</td>
<td><input name="org" type="text" class="form-control" value={appConfig().InfluxOrg}></input></td>
</tr>
<tr>
<td>Bucket</td>
<td><input name="bucket" type="text" class="form-control" value={appConfig().InfluxBucket}></input></td>
</tr>
<tr>
<td>Skip TLS verify</td>
<td>
<div class="form-check form-switch">
{appConfig().InfluxSkipTLS
? <input class="form-check-input" type="checkbox" name="skip" checked></input>
: <input class="form-check-input" type="checkbox" name="skip"></input>
}
</div>
</td>
</tr>
<tr>
<td><button type="submit" class="btn btn-primary">Save</button></td>
<td></td>
</tr>
</tbody></table>
</form>
</div>
</div>
)
}
export default Influx

View file

@ -0,0 +1,36 @@
import { apiPath } from "../../functions/api"
import { appConfig } from "../../functions/exports"
function Prometheus() {
return (
<div class="card border-primary">
<div class="card-header">Prometheus config</div>
<div class="card-body table-responsive">
<form action={apiPath + '/api/config_prometheus/'} method="post">
<table class="table table-borderless"><tbody>
<tr>
<td>Enable</td>
<td>
<div class="form-check form-switch">
{appConfig().PrometheusEnable
? <input class="form-check-input" type="checkbox" name="enable" checked></input>
: <input class="form-check-input" type="checkbox" name="enable"></input>
}
</div>
</td>
</tr>
<tr>
<td><button type="submit" class="btn btn-primary">Save</button></td>
<td>
<a href="/metrics" target="_blank">/metrics</a>
</td>
</tr>
</tbody></table>
</form>
</div>
</div>
)
}
export default Prometheus

View file

@ -0,0 +1,83 @@
import { For, Show } from "solid-js"
import { appConfig } from "../../functions/exports"
import { apiPath } from "../../functions/api"
function Scan() {
return (
<div class="card border-primary">
<div class="card-header">Scan settings</div>
<div class="card-body table-responsive">
<form action={apiPath + '/api/config_settings/'} method="post">
<table class="table table-borderless"><tbody>
<tr>
<td>Interfaces</td>
<td><input name="ifaces" type="text" class="form-control" value={appConfig().Ifaces}></input></td>
</tr>
<tr>
<td>Timeout (seconds)</td>
<td><input name="timeout" type="number" class="form-control" value={appConfig().Timeout}></input></td>
</tr>
<tr>
<td>Args for arp-scan</td>
<td><input name="arpargs" type="text" class="form-control" value={appConfig().ArpArgs}></input></td>
</tr>
<tr>
<td>Arp Strings</td>
<td>
<For each={appConfig().ArpStrs}>{arpStr =>
<input name="arpstrs" type="text" class="form-control" value={arpStr}></input>
}</For>
<input name="arpstrs" type="text" class="form-control"></input>
</td>
</tr>
<tr>
<td>Log level</td>
<td><select name="log" class="form-select">
<For each={["debug","info","warn","error"]}>{level =>
<Show
when={level == appConfig().LogLevel}
fallback={<option value={level}>{level}</option>}
>
<option value={level} selected>{level}</option>
</Show>
}</For>
</select></td>
</tr>
<tr>
<td>Trim History (hours)</td>
<td><input name="trim" type="number" class="form-control" value={appConfig().TrimHist}></input></td>
</tr>
<tr>
<td>Use DB</td>
<td><select name="usedb" class="form-select">
<Show
when={appConfig().UseDB == "sqlite"}
fallback={<>
<option value="sqlite">sqlite</option>
<option value="postgres" selected>postgres</option>
</>}
>
<option value="sqlite" selected>sqlite</option>
<option value="postgres">postgres</option>
</Show>
</select></td>
</tr>
<tr>
<td>PG Connect URL</td>
<td>
<textarea name="pgconnect" class="form-control" style="width: 100%;" rows="3" wrap="soft">{appConfig().PGConnect}</textarea>
</td>
</tr>
<tr>
<td><button type="submit" class="btn btn-primary">Save</button></td>
<td class="text-muted">*Pressing <b>Save</b> button will trigger rescan</td>
</tr>
</tbody></table>
</form>
</div>
</div>
)
}
export default Scan

View file

@ -0,0 +1,52 @@
import { createSignal, For } from "solid-js";
import { Host, ifaces, setHistUpdOnFilter } from "../functions/exports";
import { filterFunc } from "../functions/filter";
function Filter() {
type FilterEvent = Event & {
currentTarget: HTMLSelectElement;
target: HTMLSelectElement;
};
const [selectValue, setSelectValue] = createSignal("");
const handleFilter = (field: keyof Host, event: FilterEvent) => {
const value = event.target ? event.target.value : 0;
filterFunc(field, value);
setHistUpdOnFilter(true);
};
const handleReset = () => {
filterFunc("ID", 0);
setSelectValue("something");
setSelectValue("");
setHistUpdOnFilter(true);
};
return (
<div class="row">
<div class="col input-group">
<select onChange={(event)=>{handleFilter("Iface", event)}} class="form-select" title="Filter by Iface" value={selectValue()}>
<option value="" selected disabled>Iface</option>
<For each={ifaces()}>{(iface) =>
<option value={iface}>{iface}</option>
}</For>
</select>
<select onChange={(event)=>{handleFilter("Known", event)}} class="form-select" title="Filter by Known" value={selectValue()}>
<option value="" selected disabled>Known</option>
<option value={1}>Known</option>
<option value={0}>Unknown</option>
</select>
<select onChange={(event)=>{handleFilter("Now", event)}} class="form-select" title="Filter by Online" value={selectValue()}>
<option value="" selected disabled>Online</option>
<option value={1}>On</option>
<option value={0}>Off</option>
</select>
<button onClick={handleReset} class="btn btn-outline-primary" title="Reset filter">Reset filter</button>
</div>
</div>
)
}
export default Filter

View file

@ -0,0 +1,62 @@
import { createSignal } from "solid-js";
import { appConfig, setAppConfig } from "../functions/exports";
import { apiGetConfig } from "../functions/api";
function Header() {
const [themePath, setThemePath] = createSignal('');
const [iconsPath, setIconsPath] = createSignal('');
const setCurrentTheme = async () => {
setAppConfig(await apiGetConfig());
const theme = appConfig().Theme?appConfig().Theme:"sand";
const color = appConfig().Color?appConfig().Color:"dark";
if (appConfig().NodePath == '') {
setThemePath("https://cdn.jsdelivr.net/npm/aceberg-bootswatch-fork@v5.3.3-2/dist/"+theme+"/bootstrap.min.css");
setIconsPath("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css");
} else {
setThemePath(appConfig().NodePath+"/node_modules/bootswatch/dist/"+theme+"/bootstrap.min.css");
setIconsPath(appConfig().NodePath+"/node_modules/bootstrap-icons/font/bootstrap-icons.css");
}
document.documentElement.setAttribute("data-bs-theme", color);
color === "dark"
? document.documentElement.style.setProperty('--transparent-light', '#ffffff15')
: document.documentElement.style.setProperty('--transparent-light', '#00000015');
}
setCurrentTheme();
return (
<>
<link rel="stylesheet" href={iconsPath()}></link> {/* icons */}
<link rel="stylesheet" href={themePath()}></link> {/* theme */}
<nav class="navbar navbar-expand-md navbar-dark bg-primary">
<div class="container-lg">
<a class="navbar-brand" href="/">
<img src="/fs/public/favicon.png" style="width: 2em"/>
</a>
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a class="nav-link active" href="/" title="Home">Home</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/config/" title="Config">Config</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/history/" title="History">History</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active fs-3 ms-md-2" target="_blank" href="https://github.com/aceberg/WatchYourLAN" title="Github"><i class="bi bi-github"></i></a>
</li>
</ul>
</div>
</nav>
</>
)
};
export default Header

View file

@ -0,0 +1,17 @@
import { setShow, show } from "../functions/exports";
function HistShow(_props: any) {
const handleSaveShow = (showStr: string) => {
localStorage.setItem(_props.name, showStr);
setShow(+showStr);
show() == 0 ? setShow(200) : '';
};
return (
<input class="form-control" onInput={e => handleSaveShow(e.target.value)} placeholder="Show elements" title="Nomber of elements to show" style="max-width: 10em;"></input>
)
}
export default HistShow

View file

@ -0,0 +1,42 @@
import { createSignal, onMount } from "solid-js";
import { setShow } from "../../functions/exports";
import MacHistory from "../MacHistory"
function HistCard(_props: any) {
const [today, setToday] = createSignal('');
onMount(() => {
setShow(15000);
setToday(new Date().toLocaleDateString("en-CA"));
});
const handleDate = (date: string) => {
setToday("");
setToday(date);
};
return (
<div class="card border-primary">
<div class="card-header">
<div class="input-group" style="width: fit-content;">
<span class="input-group-text">Host History for</span>
<input
type="date"
class="form-control"
value={today()}
onInput={(e) => handleDate(e.currentTarget.value)}
/>
</div>
</div>
<div class="card-body">
{_props.mac !== "" && today() !== ""
? <MacHistory mac={_props.mac} date={today()}></MacHistory>
: <>Loading...</>
}
</div>
</div>
)
}
export default HistCard

View file

@ -0,0 +1,114 @@
import { apiDelHost, apiEditHost, apiWOL } from "../../functions/api";
import { debounce } from "@solid-primitives/scheduled";
function HostCard(_props: any) {
let name:string = "";
const debouncedApi = debounce(async (val: string) => {
await apiEditHost(_props.host.ID, val, "");
}, 300);
const handleInput = async (n: string) => {
name = n;
debouncedApi(n);
};
const handleToggle = async () => {
if (name == "") {
name = _props.host.Name;
}
await apiEditHost(_props.host.ID, name, 'toggle');
};
const handleDel = async () => {
await apiDelHost(_props.host.ID);
window.location.href = '/';
};
const handleWOL = async () => {
await apiWOL(_props.host.Mac);
};
return (
<div class="card border-primary">
<div class="card-header">Host</div>
<div class="card-body table-responsive">
<table class="table table-striped table-hover">
<tbody>
<tr>
<td>ID</td>
<td>{_props.host.ID}</td>
</tr>
<tr>
<td>Name</td>
<td>
<input type="text" class="form-control" value={_props.host.Name}
onInput={e => handleInput(e.target.value)}></input>
</td>
</tr>
<tr>
<td>DNS name</td>
<td>{_props.host.DNS}</td>
</tr>
<tr>
<td>Iface</td>
<td>{_props.host.Iface}</td>
</tr>
<tr>
<td>IP</td>
<td>
<a href={"http://" + _props.host.IP} target="_blank">{_props.host.IP}</a>
</td>
</tr>
<tr>
<td>MAC</td>
<td>{_props.host.Mac}</td>
</tr>
<tr>
<td>Hardware</td>
<td>{_props.host.Hw}</td>
</tr>
<tr>
<td>Date</td>
<td>{_props.host.Date}</td>
</tr>
<tr>
<td>Known</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox"
onClick={handleToggle}
checked={_props.host.Known == 1
? true
: false
}
></input>
</div>
</td>
</tr>
<tr>
<td>Online</td>
<td>{_props.host.Now == 1
? <i class="bi bi-check-circle-fill" style="color:var(--bs-success);"></i>
: <i class="bi bi-circle-fill" style="color:var(--bs-gray-500);"></i>
}
&nbsp;&nbsp;&nbsp;
<button type="button" onClick={handleWOL} class="btn btn-outline-success">Wake-on-LAN</button>
</td>
</tr>
</tbody>
</table>
<button type="button" onClick={handleDel} class="btn btn-outline-danger">Delete host</button>
</div>
</div>
)
}
export default HostCard

View file

@ -0,0 +1,76 @@
import { createSignal, For } from "solid-js";
import { apiPortScan } from "../../functions/api";
function Ping(_props: any) {
let stop = false;
const [beginStr, setBegin] = createSignal("");
const [endStr, setEnd] = createSignal("");
const [curPort, setCurPort] = createSignal("");
const [foundPorts, setFoundPorts] = createSignal<number[]>([]);
const handleScan = async () => {
stop = false;
let begin = Number(beginStr());
if (Number.isNaN(begin) || begin < 1 || begin > 65535) {
begin = 1;
}
let end = Number(endStr());
if (Number.isNaN(end) || end < 1 || end > 65535) {
end = 65535;
}
let portOpened:boolean;
for (let i = begin ; i <= end; i++) {
if (stop) {
break;
}
setCurPort(i.toString());
portOpened = await apiPortScan(_props.IP, i);
if (portOpened) {
setFoundPorts([...foundPorts(), i]);
}
}
};
const handleStop = () => {
if (stop) {
setBegin(curPort());
handleScan();
} else {
stop = true;
}
}
return (
<div class="card border-primary">
<div class="card-header">Port Scan</div>
<div class="card-body">
<form class="input-group">
<input type="text" class="form-control" placeholder="1"
onInput={e => setBegin(e.target.value)}></input>
<input type="text" class="form-control" placeholder="65535"
onInput={e => setEnd(e.target.value)}></input>
<button type="button" onClick={handleScan} class="btn btn-primary">Scan</button>
</form>
{curPort() != ""
? <div class="d-flex justify-content-between mt-2">
<button type="button" onClick={handleStop} class="btn btn-warning">Stop/Continue</button>
<div>Scanning port: {curPort()}</div>
</div>
: <></>
}
<div class="mt-2">
<For each={foundPorts()}>{(port) =>
<a class="me-4" href={"http://" + _props.IP + ":" + port} target="_blank">{port}</a>
}</For>
</div>
</div>
</div>
)
}
export default Ping

View file

@ -0,0 +1,37 @@
import { For, onCleanup, onMount, Show } from "solid-js";
import { getHistoryForMac } from "../functions/history";
import { Host, show } from "../functions/exports";
import { createStore } from "solid-js/store";
function MacHistory(_props: any) {
const [hist, setHist] = createStore<Host[]>([]);
let interval: number;
onMount(async () => {
const newHistory = await getHistoryForMac(_props.mac, _props.date);
setHist(newHistory);
interval = setInterval(async () => {
// console.log("Upd Hist", new Date());
const newHistory = await getHistoryForMac(_props.mac, _props.date);
setHist(newHistory);
}, 60000); // 60000 ms = 1 minute
});
onCleanup(() => {
clearInterval(interval);
});
return (
<For each={hist}>{(h, index) =>
<Show
when={index() < show()}
>
<i title={"Date:"+h.Date+"\nIface:"+h.Iface+"\nIP:"+h.IP+"\nKnown:"+h.Known}
class={h.Now === 0?"my-box-off":"my-box-on"}></i>
</Show>
}</For>
)
}
export default MacHistory

View file

@ -0,0 +1,14 @@
import { searchFunc } from "../functions/search";
function Search() {
const handleSearch = (s: string) => {
searchFunc(s);
};
return (
<input onInput={e => handleSearch(e.target.value)} class="form-control" placeholder="Search" style="max-width: 10em;" title="Search"></input>
)
}
export default Search

View file

@ -0,0 +1,84 @@
export const apiPath = 'http://0.0.0.0:8840';
export const apiGetAllHosts = async () => {
const url = apiPath+'/api/all';
const hosts = await (await fetch(url)).json();
return hosts;
};
export const apiGetConfig = async () => {
const url = apiPath+'/api/config';
const res = await (await fetch(url)).json();
return res;
};
export const apiGetVersion = async () => {
const url = apiPath+'/api/version';
const res = await (await fetch(url)).json();
return res;
};
export const apiTestNotify = async () => {
const url = apiPath+'/api/notify_test';
await fetch(url);
};
export const apiEditHost = async (id:number, name:string, known:string) => {
const url = apiPath+'/api/edit/'+id+'/'+name+'/'+known;
const res = await (await fetch(url)).json();
return res;
};
export const apiGetHost = async (id:string) => {
const url = apiPath+'/api/host/'+id;
const res = await (await fetch(url)).json();
return res;
};
export const apiDelHost = async (id:number) => {
const url = apiPath+'/api/host/del/'+id;
const res = await (await fetch(url)).json();
return res;
};
export const apiPortScan = async (ip:string, port:number) => {
const url = apiPath+'/api/port/'+ip+'/'+port;
const res = await (await fetch(url)).json();
return res;
};
export const apiGetHistory = async (mac:string) => {
const url = apiPath+'/api/history/'+mac+'/?num=210';
const hosts = await (await fetch(url)).json();
return hosts;
};
export const apiGetHistoryByDate = async (mac:string, date: string) => {
const url = apiPath+'/api/history/'+mac+'/'+date;
const hosts = await (await fetch(url)).json();
return hosts;
};
export const apiWOL = async (mac:string) => {
const url = apiPath+'/api/wol/'+mac;
const res = await (await fetch(url)).json();
return res;
};

View file

@ -0,0 +1,39 @@
import { apiGetAllHosts } from "./api";
import { allHosts, setAllHosts, setBkpHosts, setIfaces } from "./exports";
import { filterAtStart, filterFunc } from "./filter";
import { sortAtStart } from "./sort";
export function runAtStart() {
getHosts();
filterFunc("ID", 0); // reset filter
setInterval(() => {
getHosts();
}, 60000); // 60000 ms = 1 minute
}
export async function getHosts() {
const hosts = await apiGetAllHosts();
if (hosts !== null && hosts.length > 0) {
setAllHosts(hosts);
setBkpHosts(hosts);
listIfaces();
sortAtStart();
filterAtStart();
}
}
function listIfaces() {
let ifaces:string[] = [];
for (let host of allHosts) {
if (!ifaces.includes(host.Iface)) {
ifaces.push(host.Iface);
}
}
setIfaces(ifaces);
}

View file

@ -0,0 +1,95 @@
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
export interface Host {
ID: number;
Name: string;
DNS: string;
Iface: string;
IP: string;
Mac: string;
Hw: string;
Date: string;
Known: number;
Now: number;
};
export interface Conf {
Host: string;
Port: string;
Theme: string;
Color: string;
DirPath: string;
Timeout: number;
NodePath: string;
LogLevel: string;
Ifaces: string;
ArpArgs: string;
ArpStrs: string[];
TrimHist: number;
ShoutURL: string;
UseDB: string;
PGConnect: string;
// InfluxDB
InfluxEnable: boolean;
InfluxAddr: string;
InfluxToken: string;
InfluxOrg: string;
InfluxBucket: string;
InfluxSkipTLS: boolean;
// Prometheus
PrometheusEnable: boolean;
};
export const emptyHost:Host = {
ID: 0,
Name: "",
DNS: "",
Iface: "",
IP: "",
Mac: "",
Hw: "",
Date: "",
Known: 0,
Now: 0,
};
export const emptyConf:Conf = {
Host: "",
Port: "",
Theme: "",
Color: "",
DirPath: "",
Timeout: 120,
NodePath: "",
LogLevel: "",
Ifaces: "",
ArpArgs: "",
ArpStrs: [],
TrimHist: 48,
ShoutURL: "",
UseDB: "",
PGConnect: "",
InfluxEnable: false,
InfluxAddr: "",
InfluxToken: "",
InfluxOrg: "",
InfluxBucket: "",
InfluxSkipTLS: false,
PrometheusEnable: false,
};
export const [allHosts, setAllHosts] = createStore<Host[]>([]);
export const [bkpHosts, setBkpHosts] = createSignal<Host[]>([]);
export const [ifaces, setIfaces] = createSignal<string[]>([]);
export const [appConfig, setAppConfig] = createSignal<Conf>(emptyConf);
export const [editNames, setEditNames] = createSignal(false);
export const [show, setShow] = createSignal<number>(200);
export const [histUpdOnFilter, setHistUpdOnFilter] = createSignal(false);
export const [selectedIDs, setSelectedIDs] = createSignal<number[]>([]);

View file

@ -0,0 +1,39 @@
import { allHosts, bkpHosts, Host, setAllHosts } from "./exports";
let oldFilter = 'ID';
export function filterAtStart() {
const field = localStorage.getItem("filterField") as keyof Host;
const value = localStorage.getItem("filterValue");
filterFunc(field, value);
}
export function filterFunc(field: keyof Host, value: any) {
let addrsArray = allHosts;
if (oldFilter == field) {
addrsArray = bkpHosts();
}
oldFilter = field;
localStorage.setItem("filterField", field);
localStorage.setItem("filterValue", value);
switch (field) {
case 'Iface':
addrsArray = addrsArray.filter((item) => item.Iface == value);
break;
case 'Known':
addrsArray = addrsArray.filter((item) => item.Known == value);
break;
case 'Now':
addrsArray = addrsArray.filter((item) => item.Now == value);
break;
default:
addrsArray = bkpHosts();
}
setAllHosts(addrsArray);
}

View file

@ -0,0 +1,17 @@
import { apiGetHistory, apiGetHistoryByDate } from "./api";
import { Host } from "./exports";
export async function getHistoryForMac(mac: string, date: string) {
let h:Host[] = [];
if (date === "") {
h = await apiGetHistory(mac);
} else {
h = await apiGetHistoryByDate(mac, date);
}
if (h != null) {
h.sort((a:Host, b:Host) => (a.Date < b.Date ? 1 : -1));
return h;
}
return [];
}

View file

@ -0,0 +1,34 @@
import { allHosts, bkpHosts, Host, setAllHosts } from "./exports";
export function searchFunc(s: string) {
if (s != "") {
const sl = s.toLowerCase();
let newArray:Host[] = [];
for (let item of allHosts) {
if (searchItem(item, sl)) {
newArray.push(item);
}
}
setAllHosts(newArray);
} else {
setAllHosts(bkpHosts());
}
}
function searchItem(item:Host, sl:string) {
const name = item.Name.toLowerCase();
const hw = item.Hw.toLowerCase();
const mac = item.Mac.toLowerCase();
if ((name.includes(sl)) || (item.Iface.includes(sl)) || (item.IP.includes(sl)) || (mac.includes(sl)) || (hw.includes(sl)) || (item.Date.includes(sl))) {
return true;
} else {
return false;
}
}

View file

@ -0,0 +1,57 @@
import { bkpHosts, Host, setAllHosts } from "./exports";
let down = false;
let oldField = '';
export function sortAtStart() {
const field = localStorage.getItem("sortField") as keyof Host;
down = JSON.parse(localStorage.getItem("sortDown") as string);
down = !down;
sortByAnyField(field);
}
export function sortByAnyField(field: keyof Host) {
if (field != oldField) {
oldField = field;
down = !down;
} else {
oldField = '';
down = !down;
}
localStorage.setItem("sortDown", down.toString());
localStorage.setItem("sortField", field);
let someArray = bkpHosts();
if (field == 'IP') {
someArray.sort((a, b) => sortIP(a, b, down));
} else {
someArray.sort((a, b) => byField(a, b, field, down));
}
setAllHosts(someArray);
}
function byField(a:Host, b:Host, fieldName: keyof Host, down:boolean){
if (a[fieldName] > b[fieldName]) {
return down ? 1 : -1;
} else {
return !down ? 1 : -1;
}
}
function sortIP(a:Host, b:Host, down: boolean) {
const num1 = numIP(a);
const num2 = numIP(b);
if (down) {
return num1-num2;
} else {
return num2-num1;
}
}
function numIP(a:Host) {
return Number(a.IP.split(".").map((num) => (`000${num}`).slice(-3) ).join(""));
}

Some files were not shown because too many files have changed in this diff Show more