switch backend to pocketbase

delete frontend for now. needs to be rewritten with pocketbase sdk
This commit is contained in:
Maxi Quoß
2023-01-14 22:54:08 +01:00
parent 49f3ff78b2
commit 58d41e660f
59 changed files with 3645 additions and 9663 deletions

View File

@@ -1,4 +0,0 @@
.git
app/frontend/node_modules
app/frontend/build
**/db.sqlite3

View File

@@ -1,58 +0,0 @@
name: CI to Docker Hub
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: self-hosted
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Prepare tags
id: prep
run: |
DOCKER_IMAGE=${{ secrets.DOCKER_HUB_USERNAME }}/upsnap
VERSION=${GITHUB_REF#refs/tags/}
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${VERSION:0:2},${DOCKER_IMAGE}:latest"
echo ::set-output name=tags::${TAGS}
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
builder: ${{ steps.buildx.outputs.name }}
push: true
tags: ${{ steps.prep.outputs.tags }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

417
.gitignore vendored
View File

@@ -1,295 +1,196 @@
# Created by https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,svelte,node
# Edit at https://www.toptal.com/developers/gitignore?templates=go,visualstudiocode,svelte,node
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,python,django
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,python,django
### Django ###
*.log
*.pot
*.pyc
__pycache__/
local_settings.py
db.sqlite3
db.sqlite3-journal
media
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/
### Django.Python Stack ###
# Byte-compiled / optimized / DLL files
*.py[cod]
*$py.class
# C extensions
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Test binary, built with `go test -c`
*.test
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Dependency directories (remove the comment below to include it)
# vendor/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
pytestdebug.log
# Go workspace file
go.work
# Translations
*.mo
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Django stuff:
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Flask stuff:
instance/
.webassets-cache
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Scrapy stuff:
.scrapy
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Sphinx documentation
docs/_build/
doc/_build/
# Coverage directory used by tools like istanbul
coverage
*.lcov
# PyBuilder
target/
# nyc test coverage
.nyc_output
# Jupyter Notebook
.ipynb_checkpoints
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# IPython
profile_default/
ipython_config.py
# Bower dependency directory (https://bower.io/)
bower_components
# pyenv
.python-version
# node-waf configuration
.lock-wscript
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Dependency directories
node_modules/
jspm_packages/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# SageMath parsed files
*.sage.py
# TypeScript cache
*.tsbuildinfo
# Environments
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
pythonenv*
.env.development.local
.env.test.local
.env.production.local
.env.local
# Spyder project settings
.spyderproject
.spyproject
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Rope project settings
.ropeproject
# Next.js build output
.next
out
# mkdocs documentation
/site
# Nuxt.js build / generate output
.nuxt
dist
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# Pyre type checker
.pyre/
# vuepress build output
.vuepress/dist
# pytype static type analyzer
.pytype/
# vuepress v2.x temp and cache directory
.temp
# profiling data
.prof
# Docusaurus cache and generated files
.docusaurus
### Linux ###
*~
# Serverless directories
.serverless/
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# FuseBox cache
.fusebox/
# KDE directory preferences
.directory
# DynamoDB Local files
.dynamodb/
# Linux trash folder which might appear on any partition or disk
.Trash-*
# TernJS port file
.tern-port
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Icon must end with two \r
Icon
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Svelte ###
# gitignore template for the SvelteKit, frontend web component framework
# website: https://kit.svelte.dev/
.svelte-kit/
package
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,svelte,node
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Python ###
# Byte-compiled / optimized / DLL files
# C extensions
# Distribution / packaging
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
# Installer logs
# Unit test / coverage reports
# Translations
# Django stuff:
# Flask stuff:
# Scrapy stuff:
# Sphinx documentation
# PyBuilder
# Jupyter Notebook
# IPython
# pyenv
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
# Celery stuff
# SageMath parsed files
# Environments
# Spyder project settings
# Rope project settings
# mkdocs documentation
# mypy
# Pyre type checker
# pytype static type analyzer
# profiling data
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,python,django
.vscode
staticfiles
# JetBrains WebStorm
.idea/
# custom
docker-compose.yml
db/
*.db
backend/pb_data

View File

@@ -1,59 +1,4 @@
<div align="center" width="100%">
<img src="app/frontend/public/favicon.png" width="128" />
</div>
# to do
<div align="center" width="100%">
<h2>UpSnap</h2>
<p>A simple wake on lan app written with Svelte, Django, Django-Channels (websockets), Celery, Redis and nmap.</p>
<a target="_blank" href="https://github.com/seriousm4x/upsnap"><img src="https://img.shields.io/github/stars/seriousm4x/upsnap" /></a> <a target="_blank" href="https://hub.docker.com/r/seriousm4x/upsnap"><img src="https://img.shields.io/docker/pulls/seriousm4x/upsnap" /></a> <a target="_blank" href="https://hub.docker.com/r/seriousm4x/upsnap"><img src="https://img.shields.io/docker/v/seriousm4x/upsnap/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/seriousm4x/upsnap"><img src="https://img.shields.io/github/last-commit/seriousm4x/upsnap" /></a>
</div>
## ✨ Features
* Dashboard to wake up devices with 1 click
* Set timed wake and shutdown events via cron
* Add custom ports to devices which will be scanned
* Discover devices by scanning network
* Notifications on status changes
* Devices only get pinged when there are 1 or more visitors
* Dark/light or system prefered color scheme
* [Docker images](https://hub.docker.com/r/seriousm4x/upsnap) for amd64, arm64, arm/v7
## 📸 Screenshots
| Dark | Light |
| -------------------- | --------------------- |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/index-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/index-light.png) |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/device-settings-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/device-settings-light.png) |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/settings-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/settings-light.png) |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/add-device-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/add-device-light.png) |
## 🐳 Run your own instance
There are 3 example docker-compose files to choose from. The simplest is [docker-compose-sqlite.yml](docker-compose-sqlite.yml).
The website will be available at [localhost:8000](http://localhost:8000). If you run it on a different pc, it will be `http://<your-ip>:8000`. You can change the port in the docker-compose file.
### Reverse Proxy
If you're using a reverse proxy, make sure to set `BACKEND_IS_PROXIED` to true in docker-compose. Set your reverse proxy to the `FRONTEND_PORT` and set `/wol/` to `BACKEND_PORT`.
**Caddy example**
```
upsnap.example.com {
reverse_proxy localhost:8000
reverse_proxy /wol/ localhost:8001
}
```
### Databases
Upsnap supports 3 different databases. Postgres, MySQL and SQLite. If you already have an existing database you want to use, delete the database container from the compose file. Always make sure to set the correct database type environment variable, e.g. DB_TYPE=mysql
### Windows
There is a partly working solution in the [issue here](https://github.com/seriousm4x/UpSnap/issues/20#issuecomment-1142593360). Windows has problems with docker networking mode host, which breaks network scan. Sending wol packages works though.
## 📝 Other infos
* The app container needs to run in host network mode to send the wakeonlan command on your local network. Therefore all other containers also need to run in host network mode. I don't like it but there is no way around.
- add db import from v2
- rewrite sveltekit frontend

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,111 +0,0 @@
package controllers
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/seriousm4x/UpSnap/models"
"github.com/seriousm4x/UpSnap/queries"
)
func GetDevices(c *fiber.Ctx) error {
var devices []models.Device
if err := queries.GetAllDevices(&devices); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"error": false,
"devices": devices,
})
}
func CreateDevice(c *fiber.Ctx) error {
var device models.Device
if err := c.BodyParser(&device); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
if err := queries.CreateDevice(&device); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"error": false,
"msg": "created",
"device": device,
})
}
func PatchDevice(c *fiber.Ctx) error {
var params map[string]interface{}
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": "strconv: " + err.Error(),
})
}
if err := c.BodyParser(&params); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": "paramsparser: " + err.Error(),
})
}
if err := queries.PatchDevice(&params, id); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": true,
"msg": "patching: " + err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"error": false,
"msg": "patched",
})
}
func DeleteDevice(c *fiber.Ctx) error {
var device models.Device
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
if err := queries.GetOneDevice(&device, id); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
if err := queries.DeleteDevice(&device, id); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"error": false,
"msg": "deleted",
})
}

View File

@@ -0,0 +1,64 @@
package cronjobs
import (
"fmt"
"os"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/models"
"github.com/robfig/cron"
"github.com/seriousm4x/upsnap/backend/logger"
"github.com/seriousm4x/upsnap/backend/networking"
)
var Devices []*models.Record
func RunCron(app *pocketbase.PocketBase) {
var interval time.Duration
var err error
// get ping interval from env var
// no env: fallback to 5 seconds
// below 1 sec: fallback to 1 second
interval_default := 5 * time.Second
interval_minimum := 1 * time.Second
interval_env := os.Getenv("UPSNAP_INTERVAL")
if interval_env == "" {
interval = interval_default
} else {
interval, err = time.ParseDuration(interval_env)
if err != nil {
logger.Error.Println(err)
interval = interval_default
}
if interval < interval_minimum {
logger.Warning.Printf("Ping interval below %s is not recommended", interval_minimum)
interval = interval_minimum
}
}
logger.Debug.Println("Ping interval set to", interval)
// init cronjob
c := cron.New()
c.AddFunc(fmt.Sprintf("@every %s", interval), func() {
for _, device := range Devices {
go func(device *models.Record) {
oldStatus := device.Get("status")
newStatus := networking.PingDevice(device)
if newStatus {
if oldStatus == "offline" || oldStatus == "" {
device.Set("status", "online")
app.Dao().SaveRecord(device)
}
} else {
if oldStatus == "online" || oldStatus == "" {
device.Set("status", "offline")
app.Dao().SaveRecord(device)
}
}
}(device)
}
})
c.Run()
}

View File

@@ -1,31 +0,0 @@
package database
import (
"fmt"
"github.com/seriousm4x/UpSnap/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
func init() {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// Migrate the schema
db.AutoMigrate(&models.Settings{}, &models.Port{}, &models.Device{})
DB = db
}
func Close() error {
db, err := DB.DB()
if err != nil {
return fmt.Errorf("gorm.DB get database: %v", err)
}
return db.Close()
}

View File

@@ -1,28 +1,91 @@
module github.com/seriousm4x/UpSnap
module github.com/seriousm4x/upsnap/backend
go 1.19
require (
github.com/gofiber/fiber/v2 v2.40.1
github.com/gofiber/websocket/v2 v2.1.2
gorm.io/driver/sqlite v1.3.6
gorm.io/gorm v1.23.9
github.com/go-ping/ping v1.1.0
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/pocketbase/dbx v1.8.0
github.com/pocketbase/pocketbase v0.11.2
github.com/robfig/cron v1.2.0
)
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/fasthttp/websocket v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/AlecAivazis/survey/v2 v2.3.6 // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/aws/aws-sdk-go v1.44.178 // indirect
github.com/aws/aws-sdk-go-v2 v1.17.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.47 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.0 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.3.4 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.1 // indirect
github.com/ganigeorgiev/fexpr v0.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.12 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.6.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.41.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.28.0 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/image v0.3.0 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/oauth2 v0.4.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/term v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.5.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.106.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect
google.golang.org/grpc v1.52.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.20.2 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
)

File diff suppressed because it is too large Load Diff

30
backend/logger/logger.go Normal file
View File

@@ -0,0 +1,30 @@
package logger
import (
"log"
"os"
)
const (
flags = log.Ldate | log.Ltime | log.Lshortfile
)
var (
Info = log.New(os.Stdout, "[INFO] ", flags)
Debug = log.New(os.Stdout, "[DEBUG] ", flags)
Warning = log.New(os.Stdout, "[WARNING] ", flags)
Error = log.New(os.Stderr, "[ERROR] ", flags)
)
func init() {
// TODO: decide if you want to log to a file to max
// output := io.MultiWriter(os.Stdout, logFile)
output := os.Stdout
Info.SetOutput(output)
Error.SetOutput(output)
Debug.SetOutput(output)
log.SetOutput(Debug.Writer())
log.SetPrefix("[DEBUG]")
log.SetFlags(flags)
}

View File

@@ -1,53 +1,9 @@
package main
import (
"log"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/monitor"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/websocket/v2"
"github.com/seriousm4x/UpSnap/controllers"
"github.com/seriousm4x/upsnap/backend/pb"
)
func main() {
app := fiber.New()
app.Use(recover.New())
app.Get("/metrics", monitor.New(monitor.Config{Title: "UpSnap Metrics Page"}))
devicesGroup := app.Group("/devices")
devicesGroup.Get("/", controllers.GetDevices)
devicesGroup.Post("/", controllers.CreateDevice)
devicesGroup.Patch("/:id", controllers.PatchDevice)
devicesGroup.Delete("/:id", controllers.DeleteDevice)
app.Use("/ws", func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
c.Locals("allowed", true)
return c.Next()
}
return fiber.ErrUpgradeRequired
})
app.Get("/ws/:id", websocket.New(func(c *websocket.Conn) {
var (
mt int
msg []byte
err error
)
for {
if mt, msg, err = c.ReadMessage(); err != nil {
log.Println("read:", err)
break
}
log.Printf("recv: %s", msg)
if err = c.WriteMessage(mt, msg); err != nil {
log.Println("write:", err)
break
}
}
}))
log.Fatal(app.Listen(":3000"))
pb.StartPocketBase()
}

View File

@@ -0,0 +1,214 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `[
{
"id": "z5lghx2r3tm45n1",
"created": "2023-01-14 21:50:42.797Z",
"updated": "2023-01-14 21:50:42.797Z",
"name": "devices",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "tiqcmnjo",
"name": "name",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "1si6ajha",
"name": "ip",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "fyqmpon6",
"name": "mac",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "gdctb8hj",
"name": "link",
"type": "url",
"required": false,
"unique": false,
"options": {
"exceptDomains": null,
"onlyDomains": null
}
},
{
"system": false,
"id": "ilrwvlev",
"name": "port",
"type": "relation",
"required": false,
"unique": false,
"options": {
"maxSelect": null,
"collectionId": "cti4l8f4mz8df3r",
"cascadeDelete": false
}
},
{
"system": false,
"id": "qqvyfrex",
"name": "status",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "1a7yrwo9",
"name": "shutdown_cmd",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "s8c5z7n0",
"name": "netmask",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"options": {}
},
{
"id": "cti4l8f4mz8df3r",
"created": "2023-01-14 21:50:42.797Z",
"updated": "2023-01-14 21:50:42.797Z",
"name": "ports",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "8nwuncgg",
"name": "number",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": null,
"max": 65535
}
},
{
"system": false,
"id": "o0he3pu6",
"name": "name",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"options": {}
},
{
"id": "nmj3ko20gzkg8n3",
"created": "2023-01-14 21:50:42.797Z",
"updated": "2023-01-14 21:50:42.797Z",
"name": "settings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "ysutxavs",
"name": "interval",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": 1,
"max": null
}
}
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
}
]`
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
return nil
})
}

View File

@@ -1,14 +0,0 @@
package models
import "gorm.io/gorm"
type Device struct {
gorm.Model
Name string `gorm:"not null; size:100" json:"name" binding:"required"`
IP string `gorm:"not null" json:"ip" binding:"required"`
Mac string `gorm:"not null; size:17" json:"mac" binding:"required"`
Netmask string `gorm:"not null; size:15; default:255.255.255.0" json:"netmask" binding:"required"`
Link string `json:"link"`
Ports []Port `gorm:"many2many:device_ports;" json:"ports"`
ShutdownCmd string `json:"shutdown_cmd"`
}

View File

@@ -1,9 +0,0 @@
package models
import "gorm.io/gorm"
type Port struct {
gorm.Model
Number uint16 `gorm:"not null" json:"number" binding:"number"`
Name string `gorm:"not null" json:"name" binding:"name"`
}

View File

@@ -1,11 +0,0 @@
package models
import "gorm.io/gorm"
type Settings struct {
gorm.Model
SortBy string `gorm:"not null; default:name" json:"sort_by" binding:"required"`
ScanAddress string `gorm:"not null" json:"scan_address"`
PingInterval uint `gorm:"not null; default:5" json:"ping_interval"`
NotificationsEnabled bool `gorm:"not null; default:true" json:"notifications_enabled" binding:"required"`
}

View File

@@ -0,0 +1,45 @@
package networking
import (
"bytes"
"encoding/hex"
"fmt"
"net"
"strings"
)
func SendMagicPacket(mac, netmask string) error {
macAddr, err := hex.DecodeString(strings.ReplaceAll(mac, ":", ""))
if err != nil {
return fmt.Errorf("error decoding mac address: %v", err)
}
// Create an address to listen on
address := net.UDPAddr{
IP: net.ParseIP(netmask),
Port: 7,
}
// create a new UDP packet conn
conn, err := net.DialUDP("udp", nil, &address)
if err != nil {
return fmt.Errorf("error creating UDP packet conn: %v", err)
}
defer conn.Close()
// create a magic packet
var magicPacket bytes.Buffer
for i := 0; i < 6; i++ {
magicPacket.WriteByte(0xff)
}
for i := 0; i < 16; i++ {
magicPacket.Write(macAddr)
}
// send the magic packet
_, err = conn.Write(magicPacket.Bytes())
if err != nil {
return fmt.Errorf("error sending magic packet: %v", err)
}
return nil
}

View File

@@ -0,0 +1,28 @@
package networking
import (
"github.com/go-ping/ping"
"github.com/pocketbase/pocketbase/models"
"github.com/seriousm4x/upsnap/backend/logger"
)
func PingDevice(device *models.Record) bool {
pinger, err := ping.NewPinger(device.GetString("ip"))
if err != nil {
logger.Error.Println(err)
return false
}
pinger.Count = 1
pinger.Timeout = 500 * 1000000 // 500ms
err = pinger.Run()
if err != nil {
logger.Error.Println(err)
return false
}
stats := pinger.Statistics()
if stats.PacketLoss > 0 {
return false
} else {
return true
}
}

View File

@@ -0,0 +1,72 @@
package networking
import (
"encoding/xml"
"net"
"net/http"
"os"
"os/exec"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
)
type Nmaprun struct {
Host []struct {
Address []struct {
Addr string `xml:"addr,attr" binding:"required"`
Addrtype string `xml:"addrtype,attr" binding:"required"`
Vendor string `xml:"vendor,attr"`
} `xml:"address"`
} `xml:"host"`
}
func ScanNetwork(c echo.Context) error {
scanRange := os.Getenv("UPSNAP_SCAN_RANGE")
_, _, err := net.ParseCIDR(scanRange)
if err != nil {
return apis.NewBadRequestError(err.Error(), nil)
}
cmdOutput, err := exec.Command(
"sudo", "nmap", "-sP", "-oX", "-", scanRange,
"--host-timeout", "500ms").Output()
if err != nil {
return err
}
nmapOutput := Nmaprun{}
if err := xml.Unmarshal(cmdOutput, &nmapOutput); err != nil {
return err
}
type Device struct {
Name string `json:"name"`
IP string `json:"ip"`
MAC string `json:"mac"`
}
data := []Device{}
for _, host := range nmapOutput.Host {
dev := Device{}
for _, addr := range host.Address {
if addr.Addrtype == "ipv4" {
dev.IP = addr.Addr
} else if addr.Addrtype == "mac" {
dev.MAC = addr.Addr
}
if addr.Vendor != "" {
dev.Name = addr.Vendor
}
}
if dev.IP == "" || dev.MAC == "" {
continue
}
if dev.Name == "" {
dev.Name = "Unknown"
}
data = append(data, dev)
}
return c.JSON(http.StatusOK, data)
}

View File

@@ -0,0 +1,26 @@
package networking
import (
"time"
"github.com/pocketbase/pocketbase/models"
"github.com/seriousm4x/upsnap/backend/logger"
)
func WakeDevice(device *models.Record) bool {
err := SendMagicPacket(device.GetString("mac"), device.GetString("netmask"))
if err != nil {
logger.Error.Println(err)
return false
}
// we wait 1 minute for the device to come up
// after that, we check the state
time.Sleep(1 * time.Minute)
isOnline := PingDevice(device)
if isOnline {
return true
} else {
return false
}
}

134
backend/pb/pb.go Normal file
View File

@@ -0,0 +1,134 @@
package pb
import (
"log"
"net/http"
"os"
"os/exec"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/seriousm4x/upsnap/backend/cronjobs"
"github.com/seriousm4x/upsnap/backend/logger"
_ "github.com/seriousm4x/upsnap/backend/migrations"
"github.com/seriousm4x/upsnap/backend/networking"
)
var app *pocketbase.PocketBase
func StartPocketBase() {
app = pocketbase.New()
// auto migrate db
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
Automigrate: true,
})
// event hooks
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// set static website path
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS("./pb_public"), true))
// add wake route to api
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/upsnap/wake/:id",
Handler: func(c echo.Context) error {
record, err := app.Dao().FindFirstRecordByData("devices", "id", c.PathParam("id"))
if err != nil {
return apis.NewNotFoundError("The device does not exist.", err)
}
go func(*models.Record) {
record.Set("status", "pending")
app.Dao().SaveRecord(record)
isOnline := networking.WakeDevice(record)
if isOnline {
record.Set("status", "online")
} else {
record.Set("status", "offline")
}
app.Dao().SaveRecord(record)
}(record)
return c.JSON(http.StatusOK, record)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
// add shutdown route to api
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/upsnap/shutdown/:id",
Handler: func(c echo.Context) error {
record, err := app.Dao().FindFirstRecordByData("devices", "id", c.PathParam("id"))
if err != nil {
return apis.NewNotFoundError("The device does not exist.", err)
}
shutdown_cmd := record.GetString("shutdown_cmd")
if shutdown_cmd != "" {
cmd := exec.Command(shutdown_cmd)
if err := cmd.Run(); err != nil {
logger.Error.Println(err)
return apis.NewBadRequestError(err.Error(), record)
}
}
return nil
},
})
// add shutdown route to api
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/upsnap/scan",
Handler: networking.ScanNetwork,
})
// reset device states and run ping cronjob
devices, err := app.Dao().FindRecordsByExpr("devices")
if err != nil {
return err
}
for _, device := range devices {
device.Set("status", "offline")
if err := app.Dao().SaveRecord(device); err != nil {
return err
}
}
cronjobs.Devices = devices
go cronjobs.RunCron(app)
return nil
})
// refresh the device list on database events
app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
refreshDeviceList()
return nil
})
app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
refreshDeviceList()
return nil
})
app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
refreshDeviceList()
return nil
})
// start pocketbase
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
func refreshDeviceList() {
var err error
cronjobs.Devices, err = app.Dao().FindRecordsByExpr("devices")
if err != nil {
logger.Error.Println(err)
}
}

View File

@@ -1,51 +0,0 @@
package queries
import (
"github.com/seriousm4x/UpSnap/database"
"github.com/seriousm4x/UpSnap/models"
"gorm.io/gorm"
)
func GetAllDevices(d *[]models.Device) error {
if result := database.DB.Model(d).Find(d); result.Error != nil {
return result.Error
}
return nil
}
func CreateDevice(d *models.Device) error {
if result := database.DB.Model(d).Create(&d); result.Error != nil {
return result.Error
}
return nil
}
func GetOneDevice(d *models.Device, id int) error {
result := database.DB.Model(d).Where("id = ?", id).Find(d)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func PatchDevice(changes *map[string]interface{}, id int) error {
var device models.Device
if err := GetOneDevice(&device, id); err != nil {
return err
}
if result := database.DB.Model(device).Where("id = ?", id).Updates(changes); result.Error != nil {
return result.Error
}
return nil
}
func DeleteDevice(d *models.Device, id int) error {
var device models.Device
if result := database.DB.Where("id = ?", id).Delete(&device); result.Error != nil {
return result.Error
}
return nil
}

View File

@@ -1,63 +0,0 @@
version: "3"
services:
app:
container_name: upsnap_app
image: seriousm4x/upsnap:latest
network_mode: host
restart: unless-stopped
environment:
- FRONTEND_PORT=8000
- BACKEND_PORT=8001
- BACKEND_IS_PROXIED=false # set this to true, if you use a reverse proxy
- DB_TYPE=mysql # required
- REDIS_HOST=127.0.0.1 # required (make sure to use the same ip as below)
- REDIS_PORT=6379 # required (make sure to use the same port as below)
- DB_HOST=127.0.0.1
- DB_PORT=3306
- DB_NAME=upsnap
- DB_USER=upsnap
- DB_PASSWORD=upsnap
#- PING_INTERVAL=5 # optional (default: 5 seconds)
#- DJANGO_SUPERUSER_USER=admin # optional (default: backend login disabled)
#- DJANGO_SUPERUSER_PASSWORD=admin # optional (default: backend login disabled)
#- DJANGO_SECRET_KEY=secret # optional (default: randomly generated)
#- DJANGO_DEBUG=True # optional (default: False)
#- DJANGO_LANGUAGE_CODE=de # optional (default: en)
#- DJANGO_TIME_ZONE=Europe/Berlin # optional (default: UTC)
#- NMAP_ARGS=-sP # optional, set this if your devices need special nmap args so they can be found (default: -sP)
#- PAGE_TITLE=Custom Title # optional, set a custom page title (default: UpSnap)
depends_on:
redis:
condition: service_healthy
mysql:
condition: service_healthy
redis:
container_name: upsnap_redis
image: redis:alpine
ports:
- "6379:6379"
restart: unless-stopped
command: redis-server --loglevel warning
healthcheck:
test: redis-cli ping
interval: 10s
mysql:
container_name: upsnap_mysql
image: mariadb
restart: unless-stopped
ports:
- "3306:3306"
environment:
- MYSQL_DATABASE=upsnap
- MYSQL_USER=upsnap
- MYSQL_PASSWORD=upsnap
- MYSQL_ROOT_PASSWORD=upsnap
cap_add:
- SYS_NICE
volumes:
- upsnap_db:/var/lib/mysql
healthcheck:
test: mysqladmin ping -h localhost -u $$MYSQL_USER -p$$MYSQL_PASSWORD
interval: 10s
volumes:
upsnap_db:

View File

@@ -1,60 +0,0 @@
version: "3"
services:
app:
container_name: upsnap_app
image: seriousm4x/upsnap:latest
network_mode: host
restart: unless-stopped
environment:
- FRONTEND_PORT=8000
- BACKEND_PORT=8001
- BACKEND_IS_PROXIED=false # set this to true, if you use a reverse proxy
- DB_TYPE=postgres # required
- REDIS_HOST=127.0.0.1 # required (make sure to use the same ip as below)
- REDIS_PORT=6379 # required (make sure to use the same port as below)
- DB_HOST=127.0.0.1
- DB_PORT=5432
- DB_NAME=upsnap
- DB_USER=upsnap
- DB_PASSWORD=upsnap
#- PING_INTERVAL=5 # optional (default: 5 seconds)
#- DJANGO_SUPERUSER_USER=admin # optional (default: backend login disabled)
#- DJANGO_SUPERUSER_PASSWORD=admin # optional (default: backend login disabled)
#- DJANGO_SECRET_KEY=secret # optional (default: randomly generated)
#- DJANGO_DEBUG=True # optional (default: False)
#- DJANGO_LANGUAGE_CODE=de # optional (default: en)
#- DJANGO_TIME_ZONE=Europe/Berlin # optional (default: UTC)
#- NMAP_ARGS=-sP # optional, set this if your devices need special nmap args so they can be found (default: -sP)
#- PAGE_TITLE=Custom Title # optional, set a custom page title (default: UpSnap)
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
redis:
container_name: upsnap_redis
image: redis:alpine
ports:
- "6379:6379"
restart: unless-stopped
command: redis-server --loglevel warning
healthcheck:
test: redis-cli ping
interval: 10s
postgres:
container_name: upsnap_postgres
image: postgres:14-alpine
ports:
- "5432:5432"
restart: unless-stopped
environment:
- "POSTGRES_USER=upsnap"
- "POSTGRES_PASSWORD=upsnap"
- "POSTGRES_DB=upsnap"
healthcheck:
test: pg_isready -U upsnap
interval: 10s
volumes:
- upsnap_db:/var/lib/postgresql/data
volumes:
upsnap_db:

View File

@@ -1,47 +0,0 @@
version: "3"
services:
upsnap_frontend:
container_name: upsnap_frontend
build:
context: app/frontend/
restart: unless-stopped
environment:
- PAGE_TITLE=Custom Title # optional, set a custom page title (default: UpSnap)
ports:
- 3000:3000
upsnap_backend:
container_name: upsnap_backend
#image: seriousm4x/upsnap:latest
build:
context: app/backend/
network_mode: host
restart: unless-stopped
environment:
- BACKEND_PORT=8001
- BACKEND_IS_PROXIED=false # set this to true, if you use a reverse proxy
- DB_TYPE=sqlite # required
- REDIS_HOST=127.0.0.1 # required (make sure to use the same ip as below)
- REDIS_PORT=6379 # required (make sure to use the same port as below)
#- PING_INTERVAL=5 # optional (default: 5 seconds)
#- DJANGO_SUPERUSER_USER=admin # optional (default: backend login disabled)
#- DJANGO_SUPERUSER_PASSWORD=admin # optional (default: backend login disabled)
#- DJANGO_SECRET_KEY=secret # optional (default: randomly generated)
#- DJANGO_DEBUG=True # optional (default: False)
#- DJANGO_LANGUAGE_CODE=de # optional (default: en)
#- DJANGO_TIME_ZONE=Europe/Berlin # optional (default: UTC)
#- NMAP_ARGS=-sP # optional, set this if your devices need special nmap args so they can be found (default: -sP)
volumes:
- ./db/:/app/backend/db/
depends_on:
redis:
condition: service_healthy
redis:
container_name: upsnap_redis
image: redis:alpine
ports:
- "6379:6379"
restart: unless-stopped
command: redis-server --loglevel warning
healthcheck:
test: redis-cli ping
interval: 10s

View File

@@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@@ -1,20 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};

10
frontend/.gitignore vendored
View File

@@ -1,10 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@@ -1,14 +0,0 @@
FROM node:alpine as build
WORKDIR /app
COPY . .
RUN ls -lah && npm i && \
npm run build && \
npm prune --omit=dev
FROM node:alpine
USER node:node
WORKDIR /app
COPY --from=build --chown=node:node /app/build ./build
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json .
CMD ["node","build"]

View File

@@ -1,38 +0,0 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
{
"name": "frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.0.0",
"@sveltejs/kit": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"svelte": "^3.54.0",
"svelte-check": "^2.9.2",
"tslib": "^2.4.1",
"typescript": "^4.9.3",
"vite": "^4.0.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.2.1",
"@popperjs/core": "^2.11.6",
"bootstrap": "5.2.3",
"sass": "^1.57.0"
}
}

1919
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,58 +0,0 @@
<script>
import { browser } from '$app/environment';
if (browser) {
const preferesDark = window.matchMedia('(prefers-color-scheme: dark)');
preferesDark.addEventListener('change', (e) => {
setTheme(e.matches ? 'dark' : 'light');
});
if (localStorage.getItem('data-theme') === null) {
setTheme(preferesDark.matches ? 'dark' : 'light');
} else {
setTheme(localStorage.getItem('data-theme'));
}
}
function setTheme(color) {
const preferesDark = window.matchMedia('(prefers-color-scheme: dark)');
if (color == 'system') {
document.documentElement.setAttribute('data-theme', preferesDark ? 'dark' : 'light');
localStorage.setItem('data-theme', preferesDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', color);
localStorage.setItem('data-theme', color);
}
}
</script>
<div class="dropdown">
<button
class="btn btn-light dropdown-toggle px-3 me-2 py-2"
type="button"
id="darkModeButton"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="fa-solid fa-palette me-2" />Theme
</button>
<ul class="dropdown-menu" aria-labelledby="darkModeButton">
<li>
<button class="dropdown-item" on:click={() => setTheme('dark')}>
<i class="fa-solid fa-moon me-2" />Dark
</button>
</li>
<li>
<button class="dropdown-item" on:click={() => setTheme('light')}>
<i class="fa-solid fa-sun me-2" />Light
</button>
</li>
<li>
<button class="dropdown-item" on:click={() => setTheme('system')}>
<i class="fa-solid fa-desktop me-2" />System
</button>
</li>
</ul>
</div>

View File

@@ -1,509 +0,0 @@
<script>
import socketStore from '@stores/socket';
export let device;
let modalDevice = JSON.parse(JSON.stringify(device));
let customPort = {};
function wake(id) {
socketStore.sendMessage({
type: 'wake',
id: id
});
}
function shutdown(id) {
socketStore.sendMessage({
type: 'shutdown',
id: id
});
}
function deleteDevice() {
socketStore.sendMessage({
type: 'delete_device',
id: modalDevice.id
});
}
function updateDevice() {
device = modalDevice;
socketStore.sendMessage({
type: 'update_device',
data: modalDevice
});
}
function updatePort() {
if (!customPort.number) {
return;
}
const index = modalDevice.ports.findIndex((x) => x.number == customPort.number);
if (customPort.name) {
// add port
if (index === -1) {
customPort.checked = true;
modalDevice.ports.push(JSON.parse(JSON.stringify(customPort)));
} else {
customPort.checked = modalDevice.ports[index].checked;
modalDevice.ports[index] = JSON.parse(JSON.stringify(customPort));
}
} else {
// delete port
if (index >= 0) {
modalDevice.ports.splice(index, 1);
}
}
modalDevice = modalDevice;
// send to backend
socketStore.sendMessage({
type: 'update_port',
data: customPort
});
}
function openModal() {
modalDevice = Object.assign({}, device);
}
function validatePort() {
if (typeof customPort.number != 'number') {
customPort.number = 1;
} else if (customPort.number > 65535) {
customPort.number = 65535;
}
}
</script>
<div id="device-col-{device.id}" class="col-xs-12 col-sm-6 col-md-4 col-lg-3 g-4">
<div class="card border-0 p-3 pt-2">
<div class="card-body">
<div class="row">
<div class="col-auto me-auto">
<div id="spinner-{device.id}" class="spinner-border warning d-none" role="status" />
{#if device.up === true}
{#if device.shutdown.command}
<div
class="hover"
on:click={() => shutdown(device.id)}
on:keydown={() => shutdown(device.id)}
data-bs-toggle="tooltip"
title="Shutdown command: {device.shutdown.command}"
role="button"
>
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x success" />
</div>
{:else}
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x success" />
{/if}
{:else if device.up === false}
<div
class="hover"
on:click={() => wake(device.id)}
on:keydown={() => wake(device.id)}
role="button"
>
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x danger" />
</div>
{:else}
<div class="hover" role="button">
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x text-muted" />
</div>
{/if}
</div>
{#if device.wake.enabled}
<div class="col-auto px-2" data-bs-toggle="tooltip" title="Wake cron: {device.wake.cron}">
<i class="fa-solid fa-circle-play fa-2x text-muted" />
</div>
{/if}
{#if device.shutdown.enabled}
<div
class="col-auto px-2"
data-bs-toggle="tooltip"
title="Shutdown cron: {device.wake.cron}"
>
<i class="fa-solid fa-circle-stop fa-2x text-muted" />
</div>
{/if}
<div
class="col-auto hover"
data-bs-toggle="modal"
data-bs-target="#device-modal-{device.id}"
role="button"
on:click={() => openModal()}
on:keydown={() => openModal()}
>
<i class="fa-solid fa-ellipsis-vertical fa-2x" />
</div>
</div>
{#if device.link}
<h5 class="card-title fw-bold my-2">
<a class="inherit-color" href={device.link}>{device.name}</a>
</h5>
{:else}
<h5 class="card-title fw-bold my-2">{device.name}</h5>
{/if}
<h6 class="card-subtitle mb-2 text-muted">{device.ip}</h6>
<ul class="list-group">
{#each device.ports as port}
{#if port.checked === true}
<li class="list-group-item">
<i class="fa-solid fa-circle align-middle {port.open ? 'success' : 'danger'}" />
{port.name}
<span class="text-muted">({port.number})</span>
</li>
{/if}
{/each}
</ul>
</div>
</div>
<div
class="modal fade"
id="device-modal-{device.id}"
tabindex="-1"
aria-labelledby="device-modal-{device.id}-label"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="device-modal-{modalDevice.id}-label">
{modalDevice.name}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<form id="form-{modalDevice.id}" on:submit|preventDefault={updateDevice}>
<!-- general -->
<h5 class="fw-bold">General</h5>
<div class="row mb-2">
<div class="col-sm">
<label for="inputName{modalDevice.id}" class="form-label">Device name</label>
<input
type="text"
class="form-control"
id="inputName{modalDevice.id}"
bind:value={modalDevice.name}
required
/>
</div>
<div class="col-sm">
<label for="inputMac{modalDevice.id}" class="form-label">Mac address</label>
<input
type="text"
class="form-control"
id="inputMac{modalDevice.mac}"
bind:value={modalDevice.mac}
required
/>
</div>
</div>
<div class="row mb-2">
<div class="col-sm">
<label for="inputIp{modalDevice.id}" class="form-label">IP address</label>
<input
type="text"
class="form-control"
id="inputIp{modalDevice.id}"
bind:value={modalDevice.ip}
required
/>
</div>
<div class="col-sm">
<label for="inputNetmask{modalDevice.id}" class="form-label">Netmask</label>
<input
type="text"
class="form-control"
id="inputNetmask{modalDevice.id}"
bind:value={modalDevice.netmask}
required
/>
</div>
</div>
<div class="row">
<div class="col">
<div class="mb-3">
<label for="inputLinkAddDevice" class="form-label">Web link</label>
<input
type="text"
class="form-control"
id="inputILinkAddDevice"
placeholder="http://...."
bind:value={modalDevice.link}
/>
</div>
</div>
</div>
<!-- ports -->
<h5 class="fw-bold mt-4">Ports</h5>
<p class="mb-2">Select ports to check if they are open.</p>
{#if modalDevice.ports.length === 0}
<p class="mb-0">No ports available. Add ports below.</p>
{/if}
{#each modalDevice.ports as port}
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
id="{device.id}-port-{port.number}"
bind:checked={port['checked']}
/>
<label class="form-check-label" for="{device.id}-port-{port.number}"
>{port.name}
<span class="text-muted">({port.number})</span></label
>
</div>
{/each}
<label class="form-label mt-2" for="{device.id}-custom-port">Custom port</label>
<div class="input-group mb-2">
<input
type="text"
id="{device.id}-custom-port"
class="form-control rounded-0 rounded-start"
placeholder="Name"
aria-label="Name"
aria-describedby="button-addon2"
bind:value={customPort.name}
/>
<input
type="number"
min="1"
max="65535"
class="form-control rounded-0"
placeholder="Port"
aria-label="Port"
aria-describedby="button-addon2"
bind:value={customPort.number}
on:input={validatePort}
/>
<button
class="btn btn-secondary"
type="button"
id="button-addon2"
on:click={updatePort}>Update Port</button
>
</div>
<button
class="btn btn-secondary mt-2"
type="button"
data-bs-toggle="collapse"
data-bs-target="#info-ports"
aria-expanded="false"
aria-controls="info-ports"
>
<i class="fa-solid fa-angle-down me-2" />How to use
</button>
<div class="collapse mt-3" id="info-ports">
<div class="callout callout-info">
<p class="mb-0">
Ports must be between 1 and 65535. Enter the same port with a differen name to
change it. Leave name empty to delete port.
</p>
</div>
</div>
<!-- scheduled wake -->
<h5 class="fw-bold mt-4">Scheduled wake</h5>
<p class="mb-2">Wake your device at a given time.</p>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="wake-disable"
id="wake-radio-disabled-{modalDevice.id}"
bind:group={modalDevice['wake']['enabled']}
value={false}
checked={!modalDevice['wake']['enabled']}
/>
<label class="form-check-label" for="wake-radio-disabled-{modalDevice.id}">
Disabled
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="wake-cron"
id="wake-radio-cron-{modalDevice.id}"
bind:group={modalDevice['wake']['enabled']}
value={true}
checked={modalDevice['wake']['enabled']}
/>
<label class="form-check-label" for="wake-radio-cron-{modalDevice.id}">
Enabled
</label>
</div>
<div class="input-group my-1" hidden={!modalDevice['wake']['enabled']}>
<span class="input-group-text rounded-0 rounded-start" id="wake-cron-{modalDevice.id}"
>Cron</span
>
<input
type="text"
class="form-control rounded-0 rounded-end"
placeholder="* /4 * * *"
aria-label="Crontab"
aria-describedby="wake-cron-{modalDevice.id}"
bind:value={modalDevice['wake']['cron']}
/>
</div>
<button
class="btn btn-secondary mt-2"
type="button"
data-bs-toggle="collapse"
data-bs-target="#info-wake"
aria-expanded="false"
aria-controls="info-wake"
>
<i class="fa-solid fa-angle-down me-2" />How to use
</button>
<div class="collapse mt-3" id="info-wake">
<div class="callout callout-info">
<p class="mb-2">
Cron is a syntax describing a time pattern when to execute jobs. The above field
uses common cron syntax. Examples:
</p>
<pre class="mb-2">Minute Hour DayOfMonth Month DayOfWeek
* /4 * * * (Wake every 4 hours)
0 9 * * 1-5 (Wake from Mo-Fr at 9 a.m.)
</pre>
<p class="mb-0">
Read more about <a
href="https://linux.die.net/man/5/crontab"
target="_blank"
rel="noreferrer">valid syntax here</a
>
or
<a href="https://crontab.guru/" target="_blank" rel="noreferrer"
>use a generator</a
>. Expressions starting with "@..." are not supported.
</p>
</div>
</div>
<!-- scheduled shutdown -->
<h5 class="fw-bold mt-4">Shutdown</h5>
<p class="mb-2">
Set the shutdown command here. This shell command will be executed when clicking the
power button on the device card. You can use cron below, which will then execute the
command at the given time.
</p>
<div class="input-group">
<span
class="input-group-text rounded-0 rounded-start"
id="shutdown-command-{modalDevice.id}">Command</span
>
<input
type="text"
class="form-control rounded-0 rounded-end"
placeholder="sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo shutdown'"
aria-label="Ccommand"
aria-describedby="shutdown-command-{modalDevice.id}"
bind:value={modalDevice['shutdown']['command']}
/>
</div>
<button
class="btn btn-secondary mt-2"
type="button"
data-bs-toggle="collapse"
data-bs-target="#info-shutdown"
aria-expanded="false"
aria-controls="info-shutdown"
>
<i class="fa-solid fa-angle-down me-2" />How to use
</button>
<div class="collapse mt-3" id="info-shutdown">
<div class="callout callout-info">
<p class="mb-2">
This field takes a shell command to trigger the shutdown. You can use <code
>sshpass</code
>
for Linux or <code>net rpc</code> for Windows hosts.
</p>
<div class="callout callout-danger mb-2">
Note: This command is safed as cleartext. Meaning, passwords are clearly visible
in the database.
</div>
<p class="mb-2">Examples:</p>
<pre class="mb-2"># wake linux hosts
sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo shutdown'
# wake windows hosts
net rpc shutdown --ipaddress 192.168.1.1 --user user%password
</pre>
<p class="mb-0">
Read more about <a
href="https://linux.die.net/man/1/sshpass"
target="_blank"
rel="noreferrer">sshpass</a
>
or
<a href="https://linux.die.net/man/8/net" target="_blank" rel="noreferrer"
>net rpc</a
>.
</p>
</div>
</div>
<div>
<label class="form-label mt-2" for="{device.id}-shutdown-cron">Use cron</label>
</div>
<div class="form-check" id="{device.id}-shutdown-cron">
<input
class="form-check-input"
type="radio"
name="shutdown-disable"
id="shutdown-radio-disabled-{modalDevice.id}"
bind:group={modalDevice['shutdown']['enabled']}
value={false}
checked={!modalDevice['shutdown']['enabled']}
/>
<label class="form-check-label" for="shutdown-radio-disabled-{modalDevice.id}">
Disabled
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="shutdown-enable"
id="shutdown-radio-enabled-{modalDevice.id}"
bind:group={modalDevice['shutdown']['enabled']}
value={true}
checked={modalDevice['shutdown']['enabled']}
/>
<label class="form-check-label" for="shutdown-radio-enabled-{modalDevice.id}">
Enabled
</label>
</div>
<div class="input-group my-1" hidden={!modalDevice['shutdown']['enabled']}>
<span
class="input-group-text rounded-0 rounded-start"
id="shutdown-cron-{modalDevice.id}">Cron</span
>
<input
type="text"
class="form-control rounded-0 rounded-end"
placeholder="* /4 * * *"
aria-label="Crontab"
aria-describedby="shutdown-cron-{modalDevice.id}"
bind:value={modalDevice['shutdown']['cron']}
/>
</div>
</form>
</div>
<div class="modal-footer justify-content-between">
<button
type="button"
class="btn btn-outline-danger"
data-bs-dismiss="modal"
on:click={deleteDevice}>Delete</button
>
<button type="submit" form="form-{modalDevice.id}" class="btn btn-outline-success"
>Save changes</button
>
</div>
</div>
</div>
</div>
</div>
<style lang="scss">
</style>

View File

@@ -1,396 +0,0 @@
<script>
import socketStore from '@stores/socket';
import DarkToggle from '@components/DarkToggle.svelte';
export let visitors;
export let settings;
const pagetitle = import.meta.env.PAGE_TITLE ? import.meta.env.PAGE_TITLE : 'UpSnap';
let addDevice = {
wake: {
enabled: false,
cron: ''
},
shutdown: {
enabled: false,
cron: '',
command: ''
}
};
function updateDevice(data) {
if (Object.keys(data).length < 4) {
return;
}
socketStore.sendMessage({
type: 'update_device',
data: data
});
}
function updateSettings() {
socketStore.sendMessage({
type: 'update_settings',
data: settings
});
hideModal('settings');
}
function scanNetwork() {
socketStore.sendMessage({
type: 'scan_network'
});
const btnScan = document.querySelector('#btnScan');
const btnScanSpinner = document.querySelector('#btnScanSpinner');
const btnScanText = document.querySelector('#btnScanText');
btnScan.disabled = true;
btnScanSpinner.classList.remove('d-none');
btnScanText.innerText = 'Scanning...';
}
function addScan(i) {
document.querySelector(`#btnAdd${i}`).disabled = true;
const dev = settings.scan_network[i];
updateDevice(dev);
}
const restoreFromFile = (e) => {
let file = e.target.files[0];
let reader = new FileReader();
reader.readAsText(file);
reader.onload = (e) => {
let data = JSON.parse(e.target.result);
if (Array.isArray(data)) {
// v2 file restore
data.forEach((device) => {
updateDevice(device);
});
} else {
// v1 file restore
for (const [key, value] of Object.entries(data)) {
value['mac'] = key;
value['link'] = '';
updateDevice(value);
}
}
};
hideModal('settings');
};
function backupToFile() {
socketStore.sendMessage({
type: 'backup'
});
}
function hideModal(id) {
const modalEl = document.querySelector(`#${id}`);
const modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
}
</script>
<svelte:head>
<title>{pagetitle}</title>
</svelte:head>
<nav class="navbar navbar-expand-sm">
<div class="container-fluid">
<div class="navbar-brand" href="/">
<img src="favicon.png" alt="Logo" width="24" height="24" class="me-2" />
{pagetitle}
</div>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<span class="ms-auto d-flex">
<DarkToggle />
<div class="dropdown">
<button
class="btn btn-light dropdown-toggle px-3 me-2 py-2"
type="button"
id="dropdownMenuButton1"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="fa-solid fa-wrench me-2" />More
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li>
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#addDevice">
<i class="fa-solid fa-plus me-2" />Add device
</button>
</li>
<li>
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#settings">
<i class="fa-solid fa-sliders me-2" />Settings
</button>
</li>
</ul>
</div>
<div class="btn btn-light px-3 me-2 py-2 pe-none">
<i class="me-2 fa-solid {visitors === 1 ? 'fa-user' : 'fa-user-group'}" />{visitors}
{visitors === 1 ? 'Visitor' : 'Visitors'}
</div>
</span>
</div>
</div>
</nav>
<div
class="modal fade"
id="addDevice"
tabindex="-1"
aria-labelledby="addDeviceLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="addDeviceLabel">Add device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<form id="addForm" on:submit|preventDefault={() => updateDevice(addDevice)}>
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label for="inputNameAddDevice" class="form-label">Device name</label>
<input
type="text"
class="form-control"
id="inputNameAddDevice"
placeholder="Max PC"
bind:value={addDevice.name}
required
/>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label for="inputMacAddDevice" class="form-label">Mac address</label>
<input
type="text"
class="form-control"
id="inputMacAddDevice"
placeholder="aa:aa:aa:aa:aa:aa"
bind:value={addDevice.mac}
required
/>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label for="inputIpAddDevice" class="form-label">IP address</label>
<input
type="text"
class="form-control"
id="inputIpAddDevice"
placeholder="192.168.1.1"
bind:value={addDevice.ip}
required
/>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label for="inputNetmaskAddDevice" class="form-label">Netmask</label>
<input
type="text"
class="form-control"
id="inputNetmaskAddDevice"
placeholder="255.255.255.0"
bind:value={addDevice.netmask}
required
/>
</div>
</div>
</div>
<div class="col">
<div class="mb-3">
<label for="inputLinkAddDevice" class="form-label">Web link</label>
<input
type="text"
class="form-control"
id="inputILinkAddDevice"
placeholder="http://...."
bind:value={addDevice.link}
/>
</div>
</div>
<div class="row">
<div class="col-auto ms-auto">
<button type="submit" form="addForm" class="btn btn-outline-success"
>Add device</button
>
</div>
</div>
<h5 class="fw-bold">Network discovery</h5>
{#if !settings.discovery}
<div class="callout callout-danger mb-2">
<p class="m-0">
To enable this option, please enter your network address in the settings.
</p>
</div>
{/if}
<button
id="btnScan"
class="btn btn-secondary"
type="button"
on:click={scanNetwork}
disabled={!settings.discovery}
>
<span
id="btnScanSpinner"
class="spinner-grow spinner-grow-sm d-none"
role="status"
aria-hidden="true"
/>
<span id="btnScanText">Scan</span>
</button>
{#if settings.scan_network?.length}
<table class="table">
<thead>
<tr>
<td>Name</td>
<td>IP</td>
<td>Netmask</td>
<td>Mac</td>
<td>Add</td>
</tr>
</thead>
<tbody>
{#each settings.scan_network as device, i}
<tr>
<td>{device.name}</td>
<td>{device.ip}</td>
<td>{device.netmask}</td>
<td>{device.mac}</td>
<td>
<button
type="button"
id="btnAdd{i}"
class="btn btn-outline-secondary py-0"
on:click={() => addScan(i)}
>
<i class="fa-solid fa-plus fa-sm" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</form>
</div>
</div>
</div>
</div>
<div
class="modal fade"
id="settings"
tabindex="-1"
aria-labelledby="settingsLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold" id="settingsLabel">Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<form id="settingsForm" on:submit|preventDefault={updateSettings}>
<h5 class="fw-bold">General</h5>
<div class="row">
<div class="col-sm-8">
<div class="mb-3">
<label for="inputNetworkDiscovery" class="form-label"
>Network discovery address</label
>
<input
type="text"
class="form-control"
id="inputNetworkDiscovery"
placeholder="192.168.1.0/24"
bind:value={settings.discovery}
/>
</div>
</div>
<div class="col-sm-4">
<div class="mb-3">
<label for="inputIntervalSettings" class="form-label">Interval (seconds)</label>
<input
type="number"
class="form-control"
id="inputIntervalSettings"
min="5"
bind:value={settings.interval}
required
/>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-check">
<label class="form-check-label" for="flexCheckDefault">
Enable notifications
</label>
<input
class="form-check-input"
type="checkbox"
value=""
id="flexCheckDefault"
bind:checked={settings.notifications}
/>
</div>
</div>
</div>
</form>
<div class="row mb-3">
<div class="col-auto ms-auto">
<button type="submit" form="settingsForm" class="btn btn-outline-success">Save</button>
</div>
</div>
<h5 class="fw-bold">Backup/Restore</h5>
<div class="callout callout-info mb-2">
<p class="m-0">
Backup file structure has changed in v2. You can still restore both versions with this
file upload.
</p>
</div>
<div class="mb-3">
<label for="inputRestore" class="form-label">Restore from .json</label>
<input
id="inputRestore"
type="file"
class="form-control"
accept=".json"
on:change={(e) => restoreFromFile(e)}
/>
</div>
<div class="mb-3">
<button type="button" class="btn btn-secondary" on:click={backupToFile}>
<i class="fa-solid fa-download me-2" />
Export .json
</button>
</div>
</div>
</div>
</div>
</div>
<style lang="scss">
</style>

View File

@@ -1,23 +0,0 @@
<script>
export let toast;
</script>
<div class="position-fixed bottom-0 end-0 p-3 toast-container">
<div
class="toast fade {toast.show ? 'show' : 'hide'} {toast.color}-bg"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div class="toast-header">
<strong id="toast-title" class="me-auto">{toast.title}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close" />
</div>
<div class="toast-body fw-bold">
{toast.message}
</div>
</div>
</div>
<style lang="scss">
</style>

View File

@@ -1,188 +0,0 @@
@import '../node_modules/bootstrap/scss/bootstrap';
@import '../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss';
@import '../node_modules/@fortawesome/fontawesome-free/scss/regular.scss';
@import '../node_modules/@fortawesome/fontawesome-free/scss/solid.scss';
:root {
--success: #{$success};
--warning: #{$warning};
--danger: #{$danger};
--danger-dark-transparent: #{$danger-dark-transparent};
--info: #{$info};
--info-dark-transparent: #{$info-dark-transparent};
}
html[data-theme='light'] {
--color-bg: #{$light};
--color-text: #{$dark};
--bg-lighter: #{$light-darker};
--bg-modal: #{$light};
--svg-close: transparent
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e")
center/1em auto no-repeat;
}
html[data-theme='dark'] {
--color-bg: #{$dark};
--color-text: #{$light};
--bg-lighter: #{$dark-lighter};
--bg-modal: #{$dark-lighter};
--svg-close: transparent
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e")
center/1em auto no-repeat;
}
body {
color: var(--color-text);
background-color: var(--color-bg);
}
.modal-content {
background-color: var(--bg-modal);
}
.modal-header {
border-bottom: 1px solid var(--color-text);
.btn-close {
background: var(--svg-close);
}
}
.modal-footer {
border-top: 1px solid var(--color-text);
}
.btn,
button {
&.btn-light {
color: var(--color-text);
background-color: var(--bg-lighter);
border-width: 0px;
}
&.btn-outline-success {
border-color: $success;
&:hover {
background-color: $success;
}
}
&.btn-outline-danger {
border-color: $danger;
&:hover {
background-color: $danger;
}
}
}
.form-control {
&:focus {
border-color: inherit;
box-shadow: none;
}
}
@keyframes on-pulse {
0% {
box-shadow: 0 0 0 0 $success;
}
70% {
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
@keyframes off-pulse {
0% {
box-shadow: 0 0 0 0 $danger;
}
70% {
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
.success {
color: var(--success);
}
.warning {
color: var(--warning);
}
.danger {
color: var(--danger);
}
pre {
white-space: pre-line;
background-color: var(--color-bg);
padding: 1em;
border-radius: 1em;
}
.card {
border-radius: 2em;
background-color: var(--bg-lighter);
}
.spinner-border {
width: 1.5em;
height: 1.5em;
}
.hover {
&:hover {
text-shadow: 0px 0px 20px rgb(155, 155, 155);
transition: all 0.1s;
}
}
.fa-2x {
font-size: 1.5em;
}
.fa-power-off {
border-radius: 1em;
}
.fa-circle {
font-size: 0.8em;
}
.list-group-item {
color: var(--color-text);
}
.inherit-color {
color: inherit;
}
.callout {
padding: 1rem;
border-left-width: 0.25rem;
border-radius: 0.25rem;
&.callout-info {
background-color: var(--info-dark-transparent);
border: 1px solid var(--info-dark-transparent);
border-left: 5px solid var(--info);
}
&.callout-danger {
background-color: var(--danger-dark-transparent);
border: 1px solid var(--danger-dark-transparent);
border-left: 5px solid var(--danger);
}
}

View File

@@ -1,184 +0,0 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import socketStore from '@stores/socket';
import Navbar from '@components/Navbar.svelte';
import DeviceCard from '@components/DeviceCard.svelte';
import Toast from '@components/Toast.svelte';
let visitors = 0;
let devices = [];
let settings = {};
let toast = {
title: '',
message: '',
color: '',
show: false
};
onMount(() => {
if (!browser) {
return;
}
socketStore.subscribeStatus((status) => {
if (status == 'open') {
showToast('Websocket', 'Connected', 'success');
} else if (status == 'close') {
showToast('Websocket', 'Connection closed. Trying to reconnect ...', 'danger');
} else if (status == 'error') {
showToast('Websocket', 'Error when connecting to websocket', 'danger');
}
});
socketStore.subscribeMsg((currentMessage) => {
if (currentMessage.type == 'init') {
// create devices
devices = [...currentMessage.message.devices];
devices = devices;
devices.sort(compare);
settings = currentMessage.message.settings;
} else if (currentMessage.type == 'status') {
// set device array and sort
const index = devices.findIndex((x) => x.id == currentMessage.message.id);
if (devices.length === 0 || index === -1) {
devices.push(currentMessage.message);
devices = devices;
} else {
devices[index] = currentMessage.message;
}
devices.sort(compare);
// set device status
if (currentMessage.message.up == true) {
setUp(currentMessage.message);
} else {
setDown(currentMessage.message);
}
} else if (currentMessage.type == 'pending') {
// set device pending
setPending(currentMessage.message);
} else if (currentMessage.type == 'visitor') {
// update visitor count
visitors = currentMessage.message;
} else if (currentMessage.type == 'delete') {
// delete device
const devCol = document.querySelector(`#device-col-${currentMessage.message}`);
devCol.remove();
} else if (currentMessage.type == 'scan_network') {
// set scanned network devices
if (!currentMessage.message) {
return;
}
settings['scan_network'] = currentMessage.message;
const btnScan = document.querySelector('#btnScan');
const btnScanSpinner = document.querySelector('#btnScanSpinner');
const btnScanText = document.querySelector('#btnScanText');
btnScan.disabled = false;
btnScanSpinner.classList.add('d-none');
btnScanText.innerText = 'Scan';
} else if (currentMessage.type == 'backup') {
// download backup file
const now = new Date();
const fileName = `upsnap_backup_${now.toISOString()}.json`;
const a = document.createElement('a');
const file = new Blob([JSON.stringify(currentMessage.message)], { type: 'text/plain' });
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
} else if (currentMessage.type == 'operationStatus') {
if (currentMessage.message == 'Success') {
showToast(currentMessage.message, 'Changes were saved', 'success');
} else if (currentMessage.message == 'Error') {
showToast(
currentMessage.message,
'Error while saving the device. Please check the logs.',
'danger'
);
}
}
});
});
function setUp(device) {
const dot = document.querySelector(`#dot-${device.id}`);
const spinner = document.querySelector(`#spinner-${device.id}`);
if (dot) {
if (dot.classList.contains('danger')) {
showToast(device.name, 'Device is up!', 'success');
}
dot.style.animation = 'none';
dot.offsetWidth;
if (!spinner.classList.contains('d-none')) {
spinner.classList.add('d-none');
dot.classList.remove('d-none', 'danger');
dot.classList.add('success');
} else {
dot.style.animation = 'on-pulse 1s normal';
}
}
}
function setDown(device) {
const dot = document.querySelector(`#dot-${device.id}`);
const spinner = document.querySelector(`#spinner-${device.id}`);
if (dot) {
if (dot.classList.contains('success')) {
showToast(device.name, 'Device is down!', 'danger');
}
dot.style.animation = 'none';
dot.offsetWidth;
if (!spinner.classList.contains('d-none')) {
spinner.classList.add('d-none');
dot.classList.remove('d-none', 'success');
dot.classList.add('danger');
} else {
dot.style.animation = 'off-pulse 1s normal';
}
}
}
function setPending(id) {
const dot = document.querySelector(`#dot-${id}`);
const spinner = document.querySelector(`#spinner-${id}`);
dot.classList.add('d-none');
spinner.classList.remove('d-none');
}
function showToast(title, message, color) {
if (settings.notifications === false) {
return;
}
toast.title = title;
toast.message = message;
toast.color = color;
toast.show = true;
setTimeout(() => {
toast.show = false;
}, 4000);
}
function compare(a, b) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
</script>
<main>
<Navbar {settings} {visitors} />
<div class="container mb-3">
<div class="row">
{#each devices as device}
<DeviceCard {device} />
{/each}
</div>
</div>
<Toast {toast} />
</main>
<style lang="scss">
// @import "../main.scss";
</style>

View File

@@ -1,60 +0,0 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const BACKEND_IS_PROXIED = import.meta.env.VITE_BACKEND_IS_PROXIED;
const BACKEND_PORT = import.meta.env.VITE_BACKEND_PORT;
const status = writable('');
const message = writable('');
let socket: WebSocket;
function initSocket() {
if (BACKEND_IS_PROXIED) {
const socketUrl = new URL('ws/wol', window.location.href);
socketUrl.protocol = socketUrl.protocol.replace('http', 'ws');
socket = new WebSocket(socketUrl);
} else {
socket = new WebSocket(`ws://${location.hostname}:${BACKEND_PORT}/ws/wol`);
}
// Connection opened
socket.addEventListener('open', function () {
status.set('open');
});
// Connection closed
socket.addEventListener('close', function () {
status.set('close');
setTimeout(function () {
initSocket();
}, 3000);
});
// Connection error
socket.addEventListener('error', function () {
status.set('error');
socket.close();
});
// Listen for messages
socket.addEventListener('message', function (event) {
message.set(JSON.parse(event.data));
});
}
if (browser) {
initSocket();
}
const sendMessage = (message: string | object) => {
if (socket.readyState <= 1) {
socket.send(JSON.stringify(message));
}
};
export default {
subscribeMsg: message.subscribe,
subscribeStatus: status.subscribe,
sendMessage
};

View File

@@ -1,29 +0,0 @@
// colors
$light: #f6f6fb;
$light-darker: #e2e4eb;
$success: #06d6a0;
$danger: #ef476f;
$danger-dark-transparent: #ef476f1f;
$warning: #ffd166;
$info: #1177b2;
$info-dark-transparent: #1177b21f;
$dark: #131316;
$dark-lighter: #25252b;
// bootstrap
$list-group-border-width: 0px;
$list-group-item-padding-x: 0px;
$list-group-item-padding-y: 0px;
$list-group-bg: transparent;
$modal-content-border-radius: 2em;
$modal-inner-padding: 1.5rem;
$btn-border-radius: 0.7rem;
$dropdown-border-radius: 1em;
$dropdown-border-width: 0px;
$dropdown-min-width: 8rem;
$toast-border-radius: 0.7rem;
$toast-box-shadow: none;
$toast-border-width: 0px;
// font awesome
$fa-font-path: 'webfonts';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,20 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
import path from 'path';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
'@components': path.resolve('./src/components'),
'@stores': path.resolve('./src/stores')
}
}
};
export default config;

View File

@@ -1,3 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json"
}

View File

@@ -1,8 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()]
};
export default config;