@@ -1,5 +0,0 @@
|
||||
.git
|
||||
node_modules
|
||||
/app/frontend/public/build
|
||||
/app/frontend/scripts
|
||||
**/db.sqlite3
|
||||
58
.github/workflows/master.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: CI to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
404
.gitignore
vendored
@@ -1,294 +1,202 @@
|
||||
# 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
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
### Svelte ###
|
||||
# gitignore template for the SvelteKit, frontend web component framework
|
||||
# website: https://kit.svelte.dev/
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
.svelte-kit/
|
||||
package
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# C extensions
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Distribution / packaging
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# 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.
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# Installer logs
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,svelte,node
|
||||
|
||||
# Unit test / coverage reports
|
||||
frontend/build
|
||||
frontend/.svelte-kit
|
||||
frontend/vite.config.js.timestamp-*
|
||||
frontend/vite.config.ts.timestamp-*
|
||||
|
||||
# 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/
|
||||
backend/pb_data
|
||||
backend/pb_public/*
|
||||
!backend/pb_public/.gitkeep
|
||||
|
||||
36
Dockerfile
@@ -1,36 +0,0 @@
|
||||
FROM debian:bullseye-slim as base
|
||||
|
||||
FROM base as python-build
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /python-build
|
||||
RUN apt-get update &&\
|
||||
apt-get install -y --no-install-recommends build-essential libffi-dev libssl-dev cargo python3 python3-dev python3-pip python3-venv default-libmysqlclient-dev libpq-dev &&\
|
||||
python3 -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY app/backend/requirements.txt .
|
||||
RUN python3 -m pip install --no-cache-dir --upgrade pip wheel &&\
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM node:17-bullseye-slim as node-build
|
||||
WORKDIR /node-build
|
||||
COPY app/frontend/package*.json ./
|
||||
COPY app/frontend/src ./src
|
||||
COPY app/frontend/public ./public
|
||||
COPY app/frontend/rollup.config.js ./
|
||||
|
||||
FROM base
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update &&\
|
||||
apt-get install -y --no-install-recommends default-mysql-client nodejs npm iputils-ping nmap samba-common-bin openssh-client sshpass curl &&\
|
||||
apt-get clean &&\
|
||||
rm -rf /var/lib/{apt,dpkg,cache,log}/
|
||||
COPY --from=python-build /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY --from=node-build /node-build ./frontend
|
||||
COPY app/backend ./backend
|
||||
COPY app/run.sh ./
|
||||
|
||||
CMD ["./run.sh"]
|
||||
88
README.md
@@ -1,59 +1,55 @@
|
||||
<div align="center" width="100%">
|
||||
<img src="app/frontend/public/favicon.png" width="128" />
|
||||
</div>
|
||||
# UpSnap v3
|
||||
|
||||
<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>
|
||||
## Stable version can be found on [v2 branch](https://github.com/seriousm4x/UpSnap/tree/v2)
|
||||
|
||||
## ✨ Features
|
||||
> This is the dev branch for v3. It's very much in progress but will have a release candidate once all boxes below are checked.
|
||||
|
||||
* 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
|
||||
## Help developing
|
||||
|
||||
## 📸 Screenshots
|
||||
Fork this branch and clone it.
|
||||
|
||||
| Dark | Light |
|
||||
| -------------------- | --------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
1. Start backend
|
||||
|
||||
## 🐳 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
|
||||
}
|
||||
```sh
|
||||
cd backend
|
||||
go mod tidy
|
||||
go run main.go serve
|
||||
```
|
||||
|
||||
### Databases
|
||||
Log in to [http://127.0.0.1:8090/\_/](http://127.0.0.1:8090/_/), create an admin user and add some devices.
|
||||
|
||||
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
|
||||
2. Start frontend
|
||||
|
||||
### Windows
|
||||
```sh
|
||||
cd frontend
|
||||
pnpm i
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
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.
|
||||
Open up [http://localhost:5173/](http://localhost:5173/)
|
||||
|
||||
## 📝 Other infos
|
||||
## To do
|
||||
|
||||
* 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.
|
||||
- frontend
|
||||
|
||||
- [x] ~~add rest of settings page~~
|
||||
- [x] ~~form for adding new devices~~
|
||||
- [x] ~~add per device settings~~
|
||||
- [x] ~~theme toggle button~~
|
||||
- [x] ~~add device ports to cards~~
|
||||
|
||||
- backend
|
||||
|
||||
- [x] ~~make sure ping works~~
|
||||
- [ ] make sure arp works
|
||||
- [x] ~~make sure wake works~~
|
||||
- [ ] remove nmap?
|
||||
- [ ] add shutdown command
|
||||
- [ ] add scheduled wake
|
||||
- [x] [~~#34 Add support for WOL passwords~~](https://github.com/seriousm4x/UpSnap/issues/34)
|
||||
- [x] [~~#33 Seemingly High Ram usage~~](https://github.com/seriousm4x/UpSnap/issues/33)
|
||||
- [x] [~~#32 API available?~~](https://github.com/seriousm4x/UpSnap/issues/32)
|
||||
|
||||
- [x] ~~add db import from v2~~
|
||||
- [ ] move docker images to github packages and setup new workflow file
|
||||
- [ ] create dockerfile and docker-compose.yml
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
ASGI config for backend project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from wol.routing import ws_urlpatterns
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AuthMiddlewareStack(URLRouter(ws_urlpatterns))
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
|
||||
app = Celery("dango_wol")
|
||||
app.conf.timezone = os.getenv("DJANGO_TIME_ZONE", "UTC")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
Django settings for backend project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.0.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', get_random_secret_key())
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'channels',
|
||||
'django_celery_beat',
|
||||
'wol'
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'backend.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||
ASGI_APPLICATION = 'backend.asgi.application'
|
||||
CELERY_BROKER_URL = f"redis://{os.getenv('REDIS_HOST', '127.0.0.1')}:{os.getenv('REDIS_PORT', 6379)}"
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
if os.getenv("DB_TYPE") == "postgres":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": os.getenv("DB_NAME", "upsnap"),
|
||||
"USER": os.getenv("DB_USER", "upsnap"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", "upsnap"),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", 5432),
|
||||
"OPTIONS": {
|
||||
"connect_timeout": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
elif os.getenv("DB_TYPE") == "mysql":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "upsnap"),
|
||||
"USER": os.getenv("DB_USER", "upsnap"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", "upsnap"),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", 3306),
|
||||
"OPTIONS": {
|
||||
"connect_timeout": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
elif os.getenv("DB_TYPE") == "sqlite":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": "/app/backend/db/db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(os.getenv("REDIS_HOST", "127.0.0.1"), os.getenv("REDIS_PORT", 6379))],
|
||||
"capacity": 1000,
|
||||
"expiry": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = os.getenv("DJANGO_LANGUAGE_CODE", "en")
|
||||
|
||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
VERSION = "v2.3.2"
|
||||
@@ -1,23 +0,0 @@
|
||||
"""backend URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/4.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls)
|
||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
WSGI config for backend project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,11 +0,0 @@
|
||||
celery[redis]
|
||||
channels
|
||||
channels-redis==3.4.1
|
||||
django
|
||||
django-celery-beat
|
||||
gunicorn
|
||||
mysqlclient
|
||||
psycopg2-binary
|
||||
uvicorn[standard]
|
||||
wakeonlan
|
||||
whitenoise
|
||||
@@ -1,51 +0,0 @@
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask, PeriodicTasks
|
||||
|
||||
from wol.models import Settings, Websocket
|
||||
|
||||
# create superuser
|
||||
if os.getenv("DJANGO_SUPERUSER_USER") and os.getenv("DJANGO_SUPERUSER_PASSWORD"):
|
||||
if not User.objects.filter(username=os.getenv("DJANGO_SUPERUSER_USER")).exists():
|
||||
User.objects.create_superuser(os.getenv("DJANGO_SUPERUSER_USER"), password=os.getenv("DJANGO_SUPERUSER_PASSWORD"))
|
||||
else:
|
||||
print('Django user exists')
|
||||
|
||||
# reset visitors
|
||||
[i.delete() for i in Websocket.objects.all()]
|
||||
Websocket.objects.create(visitors=0)
|
||||
|
||||
# ping interval
|
||||
if os.environ.get("PING_INTERVAL"):
|
||||
if int(os.environ.get("PING_INTERVAL")) < 5:
|
||||
print("Ping interval lower than 5 seconds is not recommended. Please use an interval of 5 seconds or higher. Automatically set to 5 seconds.")
|
||||
ping_interval = 5
|
||||
else:
|
||||
ping_interval = int(os.environ.get("PING_INTERVAL"))
|
||||
else:
|
||||
ping_interval = 5
|
||||
Settings.objects.update_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
"interval": ping_interval
|
||||
}
|
||||
)
|
||||
|
||||
# register device ping task
|
||||
schedule, created = IntervalSchedule.objects.get_or_create(
|
||||
every=ping_interval,
|
||||
period=IntervalSchedule.SECONDS,
|
||||
)
|
||||
if created:
|
||||
PeriodicTask.objects.create(
|
||||
interval=schedule,
|
||||
name="Ping all devices",
|
||||
task="wol.tasks.ping_all_devices"
|
||||
)
|
||||
|
||||
# reset last run to fix time zone changes
|
||||
# https://django-celery-beat.readthedocs.io/en/latest/#important-warning-about-time-zones
|
||||
PeriodicTask.objects.update(last_run_at=None)
|
||||
for task in PeriodicTask.objects.all():
|
||||
PeriodicTasks.changed(task)
|
||||
@@ -1,25 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from wol.models import Device, Port, Settings
|
||||
|
||||
|
||||
class DeviceAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "ip", "mac", "netmask", "ports"]
|
||||
search_fields = ["name", "ip", "mac"]
|
||||
list_filter = ["name", "netmask"]
|
||||
|
||||
def ports(self, obj):
|
||||
return ", ".join([p.name for p in obj.port.all()])
|
||||
|
||||
class PortAdmin(admin.ModelAdmin):
|
||||
list_display = ["number", "name"]
|
||||
search_fields = ["number", "name"]
|
||||
list_filter = ["number", "name"]
|
||||
|
||||
class SettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ["sort_by", "scan_address", "interval"]
|
||||
|
||||
|
||||
admin.site.register(Device, DeviceAdmin)
|
||||
admin.site.register(Port, PortAdmin)
|
||||
admin.site.register(Settings, SettingsAdmin)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WolConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'wol'
|
||||
@@ -1,12 +0,0 @@
|
||||
import ipaddress
|
||||
import wakeonlan
|
||||
import subprocess
|
||||
|
||||
|
||||
def wake(mac, ip, netmask):
|
||||
subnet = ipaddress.ip_network(
|
||||
f"{ip}/{netmask}", strict=False).broadcast_address
|
||||
wakeonlan.send_magic_packet(mac, ip_address=str(subnet))
|
||||
|
||||
def shutdown(command):
|
||||
subprocess.run(command, shell=True)
|
||||
@@ -1,335 +0,0 @@
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytz
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from django_celery_beat.models import (CrontabSchedule, IntervalSchedule,
|
||||
PeriodicTask)
|
||||
|
||||
from wol.commands import shutdown, wake
|
||||
from wol.models import Device, Port, Settings, Websocket
|
||||
|
||||
|
||||
class WSConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
await self.channel_layer.group_add("wol", self.channel_name)
|
||||
await self.accept()
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "init",
|
||||
"message": {
|
||||
"devices": await self.get_all_devices(),
|
||||
"settings": await self.get_settings()
|
||||
}
|
||||
}))
|
||||
await self.add_visitor()
|
||||
await self.channel_layer.group_send(
|
||||
"wol", {
|
||||
"type": "send_group",
|
||||
"message": {
|
||||
"type": "visitor",
|
||||
"message": await self.get_visitors()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def disconnect(self, _):
|
||||
await self.channel_layer.group_discard("wol", self.channel_name)
|
||||
await self.remove_visitor()
|
||||
await self.channel_layer.group_send(
|
||||
"wol", {
|
||||
"type": "send_group",
|
||||
"message": {
|
||||
"type": "visitor",
|
||||
"message": await self.get_visitors()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def receive(self, text_data=None):
|
||||
received = json.loads(text_data)
|
||||
if received["type"] == "wake":
|
||||
await self.wake_device(received["id"])
|
||||
await self.channel_layer.group_send(
|
||||
"wol", {
|
||||
"type": "send_group",
|
||||
"message": {
|
||||
"type": "pending",
|
||||
"message": received["id"]
|
||||
}
|
||||
}
|
||||
)
|
||||
elif received["type"] == "shutdown":
|
||||
await self.shutdown_device(received["id"])
|
||||
await self.channel_layer.group_send(
|
||||
"wol", {
|
||||
"type": "send_group",
|
||||
"message": {
|
||||
"type": "pending",
|
||||
"message": received["id"]
|
||||
}
|
||||
}
|
||||
)
|
||||
elif received["type"] == "delete_device":
|
||||
await self.delete_device(received["id"])
|
||||
await self.channel_layer.group_send(
|
||||
"wol", {
|
||||
"type": "send_group",
|
||||
"message": {
|
||||
"type": "delete",
|
||||
"message": received["id"]
|
||||
}
|
||||
}
|
||||
)
|
||||
elif received["type"] == "update_device":
|
||||
try:
|
||||
await self.update_device(received["data"])
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "operationStatus",
|
||||
"message": "Success"
|
||||
}))
|
||||
except Exception:
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "operationStatus",
|
||||
"message": "Error"
|
||||
}))
|
||||
elif received["type"] == "update_port":
|
||||
await self.update_port(received["data"])
|
||||
elif received["type"] == "update_settings":
|
||||
await self.update_settings(received["data"])
|
||||
elif received["type"] == "scan_network":
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "scan_network",
|
||||
"message": await self.scan_network()
|
||||
}))
|
||||
elif received["type"] == "backup":
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "backup",
|
||||
"message": await self.get_all_devices()
|
||||
}))
|
||||
|
||||
async def send_group(self, event):
|
||||
await self.send(json.dumps(event["message"]))
|
||||
|
||||
@database_sync_to_async
|
||||
def add_visitor(self):
|
||||
visitor_count = Websocket.objects.first()
|
||||
visitor_count.visitors += 1
|
||||
visitor_count.save()
|
||||
|
||||
@database_sync_to_async
|
||||
def remove_visitor(self):
|
||||
visitor_count = Websocket.objects.first()
|
||||
visitor_count.visitors -= 1
|
||||
visitor_count.save()
|
||||
|
||||
@database_sync_to_async
|
||||
def get_visitors(self):
|
||||
return Websocket.objects.first().visitors
|
||||
|
||||
@database_sync_to_async
|
||||
def wake_device(self, id):
|
||||
dev = Device.objects.filter(id=id).first()
|
||||
wake(dev.mac, dev.ip, dev.netmask)
|
||||
|
||||
@database_sync_to_async
|
||||
def shutdown_device(self, id):
|
||||
dev = Device.objects.filter(id=id).first()
|
||||
shutdown(dev.shutdown_cmd)
|
||||
|
||||
@database_sync_to_async
|
||||
def get_all_devices(self):
|
||||
devices = Device.objects.all()
|
||||
d = []
|
||||
for dev in devices:
|
||||
data = {
|
||||
"id": dev.id,
|
||||
"name": dev.name,
|
||||
"ip": dev.ip,
|
||||
"mac": dev.mac,
|
||||
"netmask": dev.netmask,
|
||||
"link": dev.link,
|
||||
"ports": [],
|
||||
"wake": {
|
||||
"enabled": False,
|
||||
"cron": ""
|
||||
},
|
||||
"shutdown": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"command": dev.shutdown_cmd
|
||||
}
|
||||
}
|
||||
for p in dev.port.order_by("number"):
|
||||
data["ports"].append({
|
||||
"number": p.number,
|
||||
"name": p.name
|
||||
})
|
||||
for action in ["wake", "shutdown"]:
|
||||
try:
|
||||
task = PeriodicTask.objects.filter(
|
||||
name=f"{data['name']}-{action}",
|
||||
task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
|
||||
if task:
|
||||
wake = CrontabSchedule.objects.get(id=task.crontab_id)
|
||||
data[action]["enabled"] = task.enabled
|
||||
data[action]["cron"] = " ".join(
|
||||
[wake.minute, wake.hour, wake.day_of_month, wake.month_of_year, wake.day_of_week])
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
d.append(data)
|
||||
return d
|
||||
|
||||
@database_sync_to_async
|
||||
def delete_device(self, id):
|
||||
dev = Device.objects.get(id=id)
|
||||
dev.delete()
|
||||
|
||||
@database_sync_to_async
|
||||
def update_device(self, data):
|
||||
obj, _ = Device.objects.update_or_create(
|
||||
mac=data["mac"],
|
||||
defaults={
|
||||
"name": data["name"],
|
||||
"ip": data["ip"],
|
||||
"netmask": data["netmask"],
|
||||
"link": data["link"] if data.get("link") else "",
|
||||
"shutdown_cmd": data["shutdown"]["command"] if data.get("shutdown") else ""
|
||||
}
|
||||
)
|
||||
if data.get("ports"):
|
||||
for port in data.get("ports"):
|
||||
if port["checked"]:
|
||||
p, _ = Port.objects.get_or_create(
|
||||
number=port["number"], name=port["name"])
|
||||
obj.port.add(p)
|
||||
else:
|
||||
p = Port.objects.filter(number=port["number"]).first()
|
||||
if p and p in obj.port.all():
|
||||
obj.port.remove(p)
|
||||
|
||||
for action in ["wake", "shutdown"]:
|
||||
if data.get(action):
|
||||
if data[action]["enabled"]:
|
||||
cron = data[action]["cron"].strip().split(" ")
|
||||
if not len(cron) == 5:
|
||||
return
|
||||
minute, hour, dom, month, dow = cron
|
||||
schedule, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=dow,
|
||||
day_of_month=dom,
|
||||
month_of_year=month,
|
||||
timezone=pytz.timezone(
|
||||
os.getenv("DJANGO_TIME_ZONE", "UTC"))
|
||||
)
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=f"{data['name']}-{action}",
|
||||
defaults={
|
||||
"crontab": schedule,
|
||||
"task": f"wol.tasks.scheduled_{action}",
|
||||
"args": json.dumps([data["id"]]),
|
||||
"enabled": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
for task in PeriodicTask.objects.filter(name=f"{data['name']}-{action}", task=f"wol.tasks.scheduled_{action}"):
|
||||
task.enabled = False
|
||||
task.save()
|
||||
|
||||
@database_sync_to_async
|
||||
def update_port(self, data):
|
||||
if data.get("name"):
|
||||
Port.objects.update_or_create(
|
||||
number=data["number"],
|
||||
defaults={
|
||||
"name": data["name"]
|
||||
}
|
||||
)
|
||||
else:
|
||||
Port.objects.filter(number=data["number"]).delete()
|
||||
|
||||
@database_sync_to_async
|
||||
def get_settings(self):
|
||||
conf = Settings.objects.get(id=1)
|
||||
data = {
|
||||
"discovery": conf.scan_address,
|
||||
"interval": conf.interval,
|
||||
"scan_network": [],
|
||||
"notifications": conf.notifications
|
||||
}
|
||||
return data
|
||||
|
||||
@database_sync_to_async
|
||||
def update_settings(self, data):
|
||||
if data["interval"] > 5:
|
||||
data["interval"] = 5
|
||||
Settings.objects.update_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
"scan_address": data["discovery"],
|
||||
"interval": data["interval"],
|
||||
"notifications": data["notifications"]
|
||||
}
|
||||
)
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=data["interval"],
|
||||
period=IntervalSchedule.SECONDS,
|
||||
)
|
||||
PeriodicTask.objects.update_or_create(
|
||||
task="wol.tasks.ping_all_devices",
|
||||
defaults={
|
||||
"interval": schedule
|
||||
}
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def scan_network(self):
|
||||
data = []
|
||||
conf = Settings.objects.get(id=1)
|
||||
netmask = str(ipaddress.ip_network(conf.scan_address).netmask)
|
||||
|
||||
if not conf.scan_address:
|
||||
return
|
||||
|
||||
p = subprocess.Popen(
|
||||
["nmap", os.getenv("NMAP_ARGS", "-sP"), conf.scan_address], stdout=subprocess.PIPE)
|
||||
out = p.communicate()[0].decode("utf-8")
|
||||
ip_line = "Nmap scan report for"
|
||||
mac_line = "MAC Address:"
|
||||
|
||||
for line in out.splitlines():
|
||||
if line.startswith(ip_line):
|
||||
line_splitted = line.split()
|
||||
if len(line_splitted) == 6:
|
||||
name = line_splitted[4]
|
||||
ip = line_splitted[5]
|
||||
ip = ip.replace("(", "")
|
||||
ip = ip.replace(")", "")
|
||||
else:
|
||||
name = "Unknown"
|
||||
ip = line_splitted[4]
|
||||
elif line.startswith(mac_line):
|
||||
line_splitted = line.split()
|
||||
mac = line_splitted[2]
|
||||
data.append({
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"netmask": netmask,
|
||||
"mac": mac,
|
||||
"link": "",
|
||||
"ports": [],
|
||||
"wake": {
|
||||
"enabled": False,
|
||||
"cron": ""
|
||||
},
|
||||
"shutdown": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"command": ""
|
||||
}
|
||||
})
|
||||
return data
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-15 19:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.SlugField(max_length=100)),
|
||||
('ip', models.GenericIPAddressField()),
|
||||
('mac', models.SlugField(max_length=17)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-15 19:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='mac',
|
||||
field=models.CharField(max_length=17),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-15 22:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0002_auto_20201115_2043'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-16 02:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0003_device_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-18 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0004_remove_device_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='netmask',
|
||||
field=models.CharField(default='255.255.255.0', max_length=15),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-22 20:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0005_device_netmask'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Websocket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('visitors', models.PositiveSmallIntegerField(default=0)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-23 16:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0006_websocket'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='scheduled_wake',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-27 16:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0007_device_scheduled_wake'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Settings',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ping_interval', models.PositiveSmallIntegerField(default=5)),
|
||||
('enable_notifications', models.BooleanField(default=True)),
|
||||
('enable_console_logging', models.BooleanField(default=True)),
|
||||
('sort_by', models.SlugField(default='name')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-27 19:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0008_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='settings',
|
||||
name='ping_interval',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-28 19:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0009_remove_settings_ping_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='settings',
|
||||
name='enable_console_logging',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-28 20:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0010_alter_settings_enable_console_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='settings',
|
||||
name='scan_address',
|
||||
field=models.GenericIPAddressField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-28 23:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0011_settings_scan_address'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='name',
|
||||
field=models.SlugField(default='Unknown', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -1,64 +0,0 @@
|
||||
# Generated by Django 3.2.12 on 2022-03-12 10:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0012_alter_device_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Port',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('number', models.PositiveIntegerField()),
|
||||
('name', models.SlugField()),
|
||||
],
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='settings',
|
||||
old_name='enable_notifications',
|
||||
new_name='notifications',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='device',
|
||||
name='scheduled_wake',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='settings',
|
||||
name='enable_console_logging',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='shutdown_cmd',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settings',
|
||||
name='interval',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='settings',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='websocket',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='port',
|
||||
field=models.ManyToManyField(blank=True, to='wol.Port'),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Port(models.Model):
|
||||
number = models.PositiveIntegerField()
|
||||
name = models.SlugField()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
class Device(models.Model):
|
||||
name = models.SlugField(default="Unknown", max_length=100)
|
||||
ip = models.GenericIPAddressField()
|
||||
mac = models.CharField(max_length=17)
|
||||
netmask = models.CharField(max_length=15, default="255.255.255.0", blank=False, null=False)
|
||||
link = models.URLField(blank=True, null=True)
|
||||
port = models.ManyToManyField(Port, blank=True)
|
||||
shutdown_cmd = models.TextField(null=True, blank=True)
|
||||
|
||||
class Websocket(models.Model):
|
||||
visitors = models.PositiveSmallIntegerField(blank=False, null=False, default=0)
|
||||
|
||||
class Settings(models.Model):
|
||||
sort_by = models.SlugField(default="name")
|
||||
scan_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
interval = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
notifications = models.BooleanField(default=True)
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from wol.consumers import WSConsumer
|
||||
|
||||
ws_urlpatterns = [
|
||||
path("wol/", WSConsumer.as_asgi())
|
||||
]
|
||||
@@ -1,148 +0,0 @@
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery import shared_task
|
||||
from channels.layers import get_channel_layer
|
||||
from django.db import connection
|
||||
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
||||
|
||||
from wol.commands import shutdown, wake
|
||||
from wol.models import Device, Port, Websocket
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
|
||||
class WolDevice:
|
||||
def ping_device(self, ip):
|
||||
try:
|
||||
subprocess.check_output(["ping", "-c", "1", "-W", "0.5", ip])
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def check_port(self, ip, port):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.5)
|
||||
if sock.connect_ex((ip, port)) == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def start(self, dev):
|
||||
data = {
|
||||
"id": dev.id,
|
||||
"name": dev.name,
|
||||
"ip": dev.ip,
|
||||
"mac": dev.mac,
|
||||
"netmask": dev.netmask,
|
||||
"up": False,
|
||||
"link": dev.link,
|
||||
"ports": [],
|
||||
"wake": {
|
||||
"enabled": False,
|
||||
"cron": ""
|
||||
},
|
||||
"shutdown": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"command": dev.shutdown_cmd
|
||||
}
|
||||
}
|
||||
|
||||
# add ports
|
||||
for p in Port.objects.all().order_by("number"):
|
||||
data["ports"].append({
|
||||
"number": p.number,
|
||||
"name": p.name,
|
||||
"checked": True if p in dev.port.all() else False,
|
||||
"open": False
|
||||
})
|
||||
|
||||
# set status for device and ports
|
||||
if self.ping_device(dev.ip):
|
||||
data["up"] = True
|
||||
for port in dev.port.all():
|
||||
index = next(i for i, d in enumerate(
|
||||
data["ports"]) if d["number"] == port.number)
|
||||
if self.check_port(dev.ip, port.number):
|
||||
data["ports"][index]["checked"] = True
|
||||
data["ports"][index]["open"] = True
|
||||
else:
|
||||
data["ports"][index]["checked"] = True
|
||||
data["ports"][index]["open"] = False
|
||||
|
||||
# set cron for wake and shutdown
|
||||
for action in ["wake", "shutdown"]:
|
||||
try:
|
||||
task = PeriodicTask.objects.filter(
|
||||
name=f"{data['name']}-{action}",
|
||||
task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
|
||||
if task:
|
||||
wake = CrontabSchedule.objects.get(id=task.crontab_id)
|
||||
data[action]["enabled"] = task.enabled
|
||||
data[action]["cron"] = " ".join(
|
||||
[wake.minute, wake.hour, wake.day_of_week, wake.day_of_month, wake.month_of_year])
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"wol", {"type": "send_group", "message": {
|
||||
"type": "status",
|
||||
"message": data
|
||||
}})
|
||||
|
||||
connection.close()
|
||||
|
||||
|
||||
@shared_task
|
||||
def ping_all_devices():
|
||||
if Websocket.objects.first().visitors == 0:
|
||||
return
|
||||
|
||||
devices = Device.objects.all()
|
||||
|
||||
for dev in devices:
|
||||
d = WolDevice()
|
||||
t = threading.Thread(target=d.start, args=(dev,))
|
||||
t.start()
|
||||
|
||||
|
||||
@shared_task
|
||||
def scheduled_wake(id):
|
||||
try:
|
||||
device = Device.objects.get(id=id)
|
||||
except Device.DoesNotExist:
|
||||
for task in PeriodicTask.objects.filter(args=id):
|
||||
task.delete()
|
||||
return
|
||||
|
||||
d = WolDevice()
|
||||
up = d.ping_device(device.ip)
|
||||
if not up:
|
||||
wake(device.mac, device.ip, device.netmask)
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"wol", {"type": "send_group", "message": {
|
||||
"type": "pending",
|
||||
"message": id
|
||||
}})
|
||||
|
||||
|
||||
@shared_task
|
||||
def scheduled_shutdown(id):
|
||||
try:
|
||||
device = Device.objects.get(id=id)
|
||||
except Device.DoesNotExist:
|
||||
for task in PeriodicTask.objects.filter(args=id):
|
||||
task.delete()
|
||||
return
|
||||
|
||||
d = WolDevice()
|
||||
up = d.ping_device(device.ip)
|
||||
if up:
|
||||
shutdown(device.shutdown_cmd)
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"wol", {"type": "send_group", "message": {
|
||||
"type": "pending",
|
||||
"message": id
|
||||
}})
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
4
app/frontend/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
/node_modules/
|
||||
/public/build/
|
||||
|
||||
.DS_Store
|
||||
@@ -1,109 +0,0 @@
|
||||
*Psst — looking for a more complete solution? Check out [SvelteKit](https://kit.svelte.dev), the official framework for building web applications of all sizes, with a beautiful development experience and flexible filesystem-based routing.*
|
||||
|
||||
*Looking for a shareable component template instead? You can [use SvelteKit for that as well](https://kit.svelte.dev/docs#packaging) or the older [sveltejs/component-template](https://github.com/sveltejs/component-template)*
|
||||
|
||||
---
|
||||
|
||||
# svelte app
|
||||
|
||||
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.
|
||||
|
||||
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
|
||||
|
||||
```bash
|
||||
npx degit sveltejs/template svelte-app
|
||||
cd svelte-app
|
||||
```
|
||||
|
||||
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
Install the dependencies...
|
||||
|
||||
```bash
|
||||
cd svelte-app
|
||||
npm install
|
||||
```
|
||||
|
||||
...then start [Rollup](https://rollupjs.org):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigate to [localhost:8080](http://localhost:8080). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.
|
||||
|
||||
By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`.
|
||||
|
||||
If you're using [Visual Studio Code](https://code.visualstudio.com/) we recommend installing the official extension [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using other editors you may need to install a plugin in order to get syntax highlighting and intellisense.
|
||||
|
||||
## Building and running in production mode
|
||||
|
||||
To create an optimised version of the app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can run the newly built app with `npm run start`. This uses [sirv](https://github.com/lukeed/sirv), which is included in your package.json's `dependencies` so that the app will work when you deploy to platforms like [Heroku](https://heroku.com).
|
||||
|
||||
|
||||
## Single-page app mode
|
||||
|
||||
By default, sirv will only respond to requests that match files in `public`. This is to maximise compatibility with static fileservers, allowing you to deploy your app anywhere.
|
||||
|
||||
If you're building a single-page app (SPA) with multiple routes, sirv needs to be able to respond to requests for *any* path. You can make it so by editing the `"start"` command in package.json:
|
||||
|
||||
```js
|
||||
"start": "sirv public --single"
|
||||
```
|
||||
|
||||
## Using TypeScript
|
||||
|
||||
This template comes with a script to set up a TypeScript development environment, you can run it immediately after cloning the template with:
|
||||
|
||||
```bash
|
||||
node scripts/setupTypeScript.js
|
||||
```
|
||||
|
||||
Or remove the script via:
|
||||
|
||||
```bash
|
||||
rm scripts/setupTypeScript.js
|
||||
```
|
||||
|
||||
If you want to use `baseUrl` or `path` aliases within your `tsconfig`, you need to set up `@rollup/plugin-alias` to tell Rollup to resolve the aliases. For more info, see [this StackOverflow question](https://stackoverflow.com/questions/63427935/setup-tsconfig-path-in-svelte).
|
||||
|
||||
## Deploying to the web
|
||||
|
||||
### With [Vercel](https://vercel.com)
|
||||
|
||||
Install `vercel` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g vercel
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
cd public
|
||||
vercel deploy --name my-project
|
||||
```
|
||||
|
||||
### With [surge](https://surge.sh/)
|
||||
|
||||
Install `surge` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g surge
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
surge public my-project.surge.sh
|
||||
```
|
||||
2872
app/frontend/package-lock.json
generated
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "svelte-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"start": "sirv public --no-clear --host"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-node-resolve": "^11.0.0",
|
||||
"rollup": "^2.3.4",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-css-only": "^3.1.0",
|
||||
"rollup-plugin-livereload": "^2.0.0",
|
||||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"rollup-plugin-terser": "^7.0.0",
|
||||
"svelte": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"postcss": "^8.4.6",
|
||||
"sass": "^1.49.9",
|
||||
"sirv-cli": "^2.0.0",
|
||||
"svelte-preprocess": "^4.10.3"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
main.svelte-1tky8bj {
|
||||
text-align: center;
|
||||
padding: 1em;
|
||||
max-width: 240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1.svelte-1tky8bj {
|
||||
color: #ff3e00;
|
||||
text-transform: uppercase;
|
||||
font-size: 4em;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
main.svelte-1tky8bj {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||
|
||||
<link rel='icon' type='image/png' href='favicon.png'>
|
||||
<link rel='stylesheet' href='global.css'>
|
||||
<link rel='stylesheet' href='build/bundle.css'>
|
||||
|
||||
<script defer src='build/bundle.js'></script>
|
||||
<script defer src='build/popper.min.js'></script>
|
||||
<script defer src='build/bootstrap.min.js'></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,103 +0,0 @@
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import livereload from 'rollup-plugin-livereload';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import css from 'rollup-plugin-css-only';
|
||||
import preprocess from 'svelte-preprocess';
|
||||
import copy from 'rollup-plugin-copy';
|
||||
import dotenv from "dotenv";
|
||||
import replace from '@rollup/plugin-replace';
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const production = !process.env.ROLLUP_WATCH;
|
||||
|
||||
function serve() {
|
||||
let server;
|
||||
|
||||
function toExit() {
|
||||
if (server) server.kill(0);
|
||||
}
|
||||
|
||||
return {
|
||||
writeBundle() {
|
||||
if (server) return;
|
||||
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
|
||||
process.on('SIGTERM', toExit);
|
||||
process.on('exit', toExit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
input: 'src/main.js',
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'iife',
|
||||
name: 'app',
|
||||
file: 'public/build/bundle.js'
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
},
|
||||
preprocess: preprocess(),
|
||||
}),
|
||||
replace({
|
||||
BACKEND_PORT: JSON.stringify(process.env.BACKEND_PORT),
|
||||
BACKEND_IS_PROXIED: JSON.stringify(process.env.BACKEND_IS_PROXIED.toLowerCase() === 'true'),
|
||||
PAGE_TITLE: JSON.stringify(process.env.PAGE_TITLE),
|
||||
preventAssignment: true
|
||||
}),
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file - better for performance
|
||||
css({ output: 'bundle.css' }),
|
||||
copy({
|
||||
targets: [
|
||||
{
|
||||
src: [
|
||||
"node_modules/@fortawesome/fontawesome-free/webfonts",
|
||||
"node_modules/bootstrap/dist/js/bootstrap.min.js",
|
||||
"node_modules/bootstrap/dist/js/bootstrap.min.js.map",
|
||||
"node_modules/@popperjs/core/dist/umd/popper.min.js",
|
||||
"node_modules/@popperjs/core/dist/umd/popper.min.js.map"
|
||||
],
|
||||
dest: "public/build/"
|
||||
}
|
||||
]
|
||||
}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration -
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte']
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
// In dev mode, call `npm run start` once
|
||||
// the bundle has been generated
|
||||
!production && serve(),
|
||||
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload('public'),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
production && terser()
|
||||
],
|
||||
watch: {
|
||||
clearScreen: false
|
||||
}
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/** This script modifies the project to support TS code in .svelte files like:
|
||||
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
</script>
|
||||
|
||||
As well as validating the code for CI.
|
||||
*/
|
||||
|
||||
/** To work on this script:
|
||||
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { argv } = require("process")
|
||||
|
||||
const projectRoot = argv[2] || path.join(__dirname, "..")
|
||||
|
||||
// Add deps to pkg.json
|
||||
const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))
|
||||
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
|
||||
"svelte-check": "^2.0.0",
|
||||
"svelte-preprocess": "^4.0.0",
|
||||
"@rollup/plugin-typescript": "^8.0.0",
|
||||
"typescript": "^4.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"@tsconfig/svelte": "^2.0.0"
|
||||
})
|
||||
|
||||
// Add script for checking
|
||||
packageJSON.scripts = Object.assign(packageJSON.scripts, {
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
})
|
||||
|
||||
// Write the package JSON
|
||||
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " "))
|
||||
|
||||
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
|
||||
const beforeMainJSPath = path.join(projectRoot, "src", "main.js")
|
||||
const afterMainTSPath = path.join(projectRoot, "src", "main.ts")
|
||||
fs.renameSync(beforeMainJSPath, afterMainTSPath)
|
||||
|
||||
// Switch the app.svelte file to use TS
|
||||
const appSveltePath = path.join(projectRoot, "src", "App.svelte")
|
||||
let appFile = fs.readFileSync(appSveltePath, "utf8")
|
||||
appFile = appFile.replace("<script>", '<script lang="ts">')
|
||||
appFile = appFile.replace("export let name;", 'export let name: string;')
|
||||
fs.writeFileSync(appSveltePath, appFile)
|
||||
|
||||
// Edit rollup config
|
||||
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
|
||||
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
|
||||
|
||||
// Edit imports
|
||||
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
import typescript from '@rollup/plugin-typescript';`)
|
||||
|
||||
// Replace name of entry point
|
||||
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
|
||||
|
||||
// Add preprocessor
|
||||
rollupConfig = rollupConfig.replace(
|
||||
'compilerOptions:',
|
||||
'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:'
|
||||
);
|
||||
|
||||
// Add TypeScript
|
||||
rollupConfig = rollupConfig.replace(
|
||||
'commonjs(),',
|
||||
'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),'
|
||||
);
|
||||
fs.writeFileSync(rollupConfigPath, rollupConfig)
|
||||
|
||||
// Add TSConfig
|
||||
const tsconfig = `{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
|
||||
}`
|
||||
const tsconfigPath = path.join(projectRoot, "tsconfig.json")
|
||||
fs.writeFileSync(tsconfigPath, tsconfig)
|
||||
|
||||
// Add global.d.ts
|
||||
const dtsPath = path.join(projectRoot, "src", "global.d.ts")
|
||||
fs.writeFileSync(dtsPath, `/// <reference types="svelte" />`)
|
||||
|
||||
// Delete this script, but not during testing
|
||||
if (!argv[2]) {
|
||||
// Remove the script
|
||||
fs.unlinkSync(path.join(__filename))
|
||||
|
||||
// Check for Mac's DS_store file, and if it's the only one left remove it
|
||||
const remainingFiles = fs.readdirSync(path.join(__dirname))
|
||||
if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {
|
||||
fs.unlinkSync(path.join(__dirname, '.DS_store'))
|
||||
}
|
||||
|
||||
// Check if the scripts folder is empty
|
||||
if (fs.readdirSync(path.join(__dirname)).length === 0) {
|
||||
// Remove the scripts folder
|
||||
fs.rmdirSync(path.join(__dirname))
|
||||
}
|
||||
}
|
||||
|
||||
// Adds the extension recommendation
|
||||
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })
|
||||
fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
`)
|
||||
|
||||
console.log("Converted to TypeScript.")
|
||||
|
||||
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
|
||||
console.log("\nYou will need to re-run your dependency manager to get started.")
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import socketStore from "./socketStore.js";
|
||||
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(() => {
|
||||
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" global>
|
||||
@import "./variables.scss";
|
||||
@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";
|
||||
@import "theme.scss";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script>
|
||||
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) {
|
||||
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>
|
||||
@@ -1,671 +0,0 @@
|
||||
<script>
|
||||
import socketStore from "../socketStore.js";
|
||||
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">
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -1,505 +0,0 @@
|
||||
<script>
|
||||
import socketStore from "../socketStore.js";
|
||||
import DarkToggle from "./DarkToggle.svelte";
|
||||
export let visitors;
|
||||
export let settings;
|
||||
const pagetitle = PAGE_TITLE ? 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" global>
|
||||
.table {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: var(--bg-lighter);
|
||||
|
||||
li:first-child > button {
|
||||
border-radius: 0.5rem 0.5rem 0rem 0rem;
|
||||
}
|
||||
|
||||
li:last-child > button {
|
||||
border-radius: 0rem 0rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
li:only-child > button {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--color-text);
|
||||
background-color: var(--bg-lighter);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-bg);
|
||||
background-color: var(--color-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,46 +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">
|
||||
@import "../variables.scss";
|
||||
|
||||
.toast-container {
|
||||
z-index: 11;
|
||||
border-radius: $toast-border-radius;
|
||||
|
||||
div > .toast-body {
|
||||
color: $dark-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
.success-bg {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.danger-bg {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
|
||||
const app = new App({
|
||||
target: document.body
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,57 +0,0 @@
|
||||
import {
|
||||
writable
|
||||
} from 'svelte/store';
|
||||
|
||||
const status = writable('');
|
||||
const message = writable('');
|
||||
let socket;
|
||||
|
||||
function initSocket() {
|
||||
if (BACKEND_IS_PROXIED) {
|
||||
const socketUrl = new URL('wol/', window.location.href);
|
||||
socketUrl.protocol = socketUrl.protocol.replace('http', 'ws');
|
||||
|
||||
socket = new WebSocket(socketUrl);
|
||||
}
|
||||
else {
|
||||
socket = new WebSocket(`ws://${location.hostname}:${BACKEND_PORT}/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));
|
||||
});
|
||||
}
|
||||
|
||||
initSocket()
|
||||
|
||||
const sendMessage = (message) => {
|
||||
if (socket.readyState <= 1) {
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
subscribeMsg: message.subscribe,
|
||||
subscribeStatus: status.subscribe,
|
||||
sendMessage
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
@@ -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";
|
||||
17
app/run.sh
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# init django
|
||||
cd /app/backend/ || exit
|
||||
python3 manage.py makemigrations
|
||||
python3 manage.py migrate
|
||||
python3 manage.py collectstatic --noinput
|
||||
python3 manage.py shell < setup.py
|
||||
python3 -m celery -A backend worker &
|
||||
python3 -m celery -A backend beat &
|
||||
python3 -m gunicorn --bind 0.0.0.0:"${BACKEND_PORT}" --workers 4 backend.asgi:application -k uvicorn.workers.UvicornWorker &
|
||||
|
||||
# start frontend
|
||||
cd /app/frontend/ || exit
|
||||
npm install
|
||||
npm run build
|
||||
PORT=$FRONTEND_PORT npm start
|
||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB |
74
backend/cronjobs/cronjobs.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package cronjobs
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/seriousm4x/upsnap/backend/logger"
|
||||
"github.com/seriousm4x/upsnap/backend/networking"
|
||||
)
|
||||
|
||||
var Devices []*models.Record
|
||||
var Jobs *cron.Cron
|
||||
|
||||
func RunCron(app *pocketbase.PocketBase) {
|
||||
settingsRecords, err := app.Dao().FindRecordsByExpr("settings")
|
||||
if err != nil {
|
||||
logger.Error.Println(err)
|
||||
}
|
||||
|
||||
// init cronjob
|
||||
Jobs = cron.New()
|
||||
Jobs.AddFunc(settingsRecords[0].GetString("interval"), func() {
|
||||
// skip cron if no realtime clients connected
|
||||
realtimeClients := len(app.SubscriptionsBroker().Clients())
|
||||
if realtimeClients == 0 {
|
||||
return
|
||||
}
|
||||
// expand ports field
|
||||
expandFetchFunc := func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
}
|
||||
merr := app.Dao().ExpandRecords(Devices, []string{"ports"}, expandFetchFunc)
|
||||
if len(merr) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, device := range Devices {
|
||||
// ping
|
||||
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)
|
||||
|
||||
// scan ports
|
||||
go func(device *models.Record) {
|
||||
ports, err := app.Dao().FindRecordsByIds("ports", device.GetStringSlice("ports"))
|
||||
if err != nil {
|
||||
logger.Error.Println(err)
|
||||
}
|
||||
for _, port := range ports {
|
||||
isUp := networking.CheckPort(device.GetString("ip"), port.GetString("number"))
|
||||
if isUp != port.GetBool("status") {
|
||||
port.Set("status", isUp)
|
||||
app.Dao().SaveRecord(port)
|
||||
device.RefreshUpdated()
|
||||
app.Dao().SaveRecord(device)
|
||||
}
|
||||
}
|
||||
}(device)
|
||||
}
|
||||
})
|
||||
Jobs.Run()
|
||||
}
|
||||
96
backend/go.mod
Normal file
@@ -0,0 +1,96 @@
|
||||
module github.com/seriousm4x/upsnap/backend
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/go-ping/ping v1.1.0
|
||||
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
|
||||
github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a
|
||||
github.com/pocketbase/dbx v1.8.0
|
||||
github.com/pocketbase/pocketbase v0.11.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/josharian/native v1.0.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.17 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
||||
github.com/mdlayher/ethernet v0.0.0-20190313224307-5b5fc417d966 // indirect
|
||||
github.com/mdlayher/packet v1.0.0 // indirect
|
||||
github.com/mdlayher/socket v0.2.1 // 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/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
|
||||
)
|
||||
2816
backend/go.sum
Normal file
30
backend/logger/logger.go
Normal 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)
|
||||
}
|
||||
9
backend/main.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/seriousm4x/upsnap/backend/pb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pb.StartPocketBase()
|
||||
}
|
||||
189
backend/migrations/1674743322_created_devices.go
Normal file
@@ -0,0 +1,189 @@
|
||||
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-26 14:28:42.378Z",
|
||||
"updated": "2023-01-26 14:28:42.378Z",
|
||||
"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": "s8c5z7n0",
|
||||
"name": "netmask",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "qqvyfrex",
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ilrwvlev",
|
||||
"name": "ports",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": null,
|
||||
"collectionId": "cti4l8f4mz8df3r",
|
||||
"cascadeDelete": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gdctb8hj",
|
||||
"name": "link",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"exceptDomains": null,
|
||||
"onlyDomains": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "kzvdbbws",
|
||||
"name": "wake_cron",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "91bs6clw",
|
||||
"name": "shutdown_cron",
|
||||
"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": "iarksm3l",
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"options": {}
|
||||
}`
|
||||
|
||||
collection := &models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).SaveCollection(collection)
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("z5lghx2r3tm45n1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.DeleteCollection(collection)
|
||||
})
|
||||
}
|
||||
81
backend/migrations/1674743322_created_ports.go
Normal file
@@ -0,0 +1,81 @@
|
||||
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": "cti4l8f4mz8df3r",
|
||||
"created": "2023-01-26 14:28:42.378Z",
|
||||
"updated": "2023-01-26 14:28:42.378Z",
|
||||
"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": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "w0bh39gv",
|
||||
"name": "status",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
}
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"options": {}
|
||||
}`
|
||||
|
||||
collection := &models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).SaveCollection(collection)
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("cti4l8f4mz8df3r")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.DeleteCollection(collection)
|
||||
})
|
||||
}
|
||||
69
backend/migrations/1674743322_created_settings.go
Normal file
@@ -0,0 +1,69 @@
|
||||
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": "nmj3ko20gzkg8n3",
|
||||
"created": "2023-01-26 14:28:42.378Z",
|
||||
"updated": "2023-01-26 14:28:42.378Z",
|
||||
"name": "settings",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "ph88gaa5",
|
||||
"name": "interval",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "7ehvglvv",
|
||||
"name": "notifications",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
}
|
||||
],
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"options": {}
|
||||
}`
|
||||
|
||||
collection := &models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).SaveCollection(collection)
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("nmj3ko20gzkg8n3")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.DeleteCollection(collection)
|
||||
})
|
||||
}
|
||||
90
backend/migrations/1674743322_deleted_users.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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 {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("_pb_users_auth_")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.DeleteCollection(collection)
|
||||
}, func(db dbx.Builder) error {
|
||||
jsonData := `{
|
||||
"id": "_pb_users_auth_",
|
||||
"created": "2023-01-26 14:28:20.206Z",
|
||||
"updated": "2023-01-26 14:28:20.206Z",
|
||||
"name": "users",
|
||||
"type": "auth",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "users_name",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "users_avatar",
|
||||
"name": "avatar",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"mimeTypes": [
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
],
|
||||
"thumbs": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"listRule": "id = @request.auth.id",
|
||||
"viewRule": "id = @request.auth.id",
|
||||
"createRule": "",
|
||||
"updateRule": "id = @request.auth.id",
|
||||
"deleteRule": "id = @request.auth.id",
|
||||
"options": {
|
||||
"allowEmailAuth": true,
|
||||
"allowOAuth2Auth": true,
|
||||
"allowUsernameAuth": true,
|
||||
"exceptEmailDomains": null,
|
||||
"manageRule": null,
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": null,
|
||||
"requireEmail": false
|
||||
}
|
||||
}`
|
||||
|
||||
collection := &models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).SaveCollection(collection)
|
||||
})
|
||||
}
|
||||
74
backend/networking/magicpacket.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package networking
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/mdlayher/wol"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func SendMagicPacket(device *models.Record) error {
|
||||
ip := device.GetString("ip")
|
||||
mac := device.GetString("mac")
|
||||
netmask := device.GetString("netmask")
|
||||
password := device.GetString("password")
|
||||
|
||||
// parse inputs
|
||||
parsedIP := net.ParseIP(ip)
|
||||
if parsedIP == nil {
|
||||
return fmt.Errorf("error: invalid ip address")
|
||||
}
|
||||
parsedMac, err := net.ParseMAC(mac)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsedNetmask := net.ParseIP(netmask)
|
||||
if parsedNetmask == nil {
|
||||
return fmt.Errorf("error: invalid netmask")
|
||||
}
|
||||
var bytePassword []byte
|
||||
if len(password) == 0 || len(password) == 4 || len(password) == 6 {
|
||||
bytePassword = []byte(password)
|
||||
} else {
|
||||
return fmt.Errorf("error: password must be 0, 4 or 6 characters long")
|
||||
}
|
||||
|
||||
// get target addr
|
||||
ipNet := &net.IPNet{
|
||||
IP: parsedIP,
|
||||
Mask: parsedNetmask.DefaultMask(),
|
||||
}
|
||||
broadcastIp, err := getBroadcastIp(ipNet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetAddr := fmt.Sprintf("%s:%d", broadcastIp, 9)
|
||||
|
||||
// send wake via udp port 9
|
||||
if err := wakeUDP(targetAddr, parsedMac, bytePassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func wakeUDP(addr string, target net.HardwareAddr, password []byte) error {
|
||||
c, err := wol.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
return c.WakePassword(addr, target, password)
|
||||
}
|
||||
|
||||
func getBroadcastIp(n *net.IPNet) (net.IP, error) {
|
||||
if n.IP.To4() == nil {
|
||||
return net.IP{}, errors.New("does not support IPv6 addresses")
|
||||
}
|
||||
ip := make(net.IP, len(n.IP.To4()))
|
||||
binary.BigEndian.PutUint32(ip, binary.BigEndian.Uint32(n.IP.To4())|^binary.BigEndian.Uint32(net.IP(n.Mask).To4()))
|
||||
return ip, nil
|
||||
}
|
||||
48
backend/networking/ping.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package networking
|
||||
|
||||
import (
|
||||
"net"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"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
|
||||
if runtime.GOOS == "windows" {
|
||||
pinger.SetPrivileged(true)
|
||||
}
|
||||
err = pinger.Run()
|
||||
if err != nil {
|
||||
logger.Error.Println(err)
|
||||
return false
|
||||
}
|
||||
stats := pinger.Statistics()
|
||||
if stats.PacketLoss > 0 {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func CheckPort(host string, port string) bool {
|
||||
timeout := 500 * time.Millisecond
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if conn != nil {
|
||||
defer conn.Close()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
26
backend/networking/wake.go
Normal 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
116
backend/pb/handlers.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/seriousm4x/upsnap/backend/logger"
|
||||
"github.com/seriousm4x/upsnap/backend/networking"
|
||||
)
|
||||
|
||||
func HandlerWake(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)
|
||||
}
|
||||
|
||||
func HandlerShutdown(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
|
||||
}
|
||||
|
||||
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 HandlerScan(c echo.Context) error {
|
||||
scanRange := os.Getenv("UPSNAP_SCAN_RANGE")
|
||||
_, _, err := net.ParseCIDR(scanRange)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), nil)
|
||||
}
|
||||
|
||||
cmd := []string{}
|
||||
if runtime.GOOS != "windows" {
|
||||
cmd = []string{"sudo"}
|
||||
}
|
||||
cmd = append(cmd, "nmap", "-sn", "-oX", "-", scanRange, "--host-timeout", "500ms")
|
||||
|
||||
cmdOutput, err := exec.Command(strings.Join(cmd, " ")).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)
|
||||
}
|
||||
185
backend/pb/pb.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var App *pocketbase.PocketBase
|
||||
|
||||
func StartPocketBase() {
|
||||
App = pocketbase.New()
|
||||
App.RootCmd.Short = "UpSnap CLI"
|
||||
App.RootCmd.Version = "3.0.0b1"
|
||||
|
||||
// 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: HandlerWake,
|
||||
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: HandlerShutdown,
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.ActivityLogger(App),
|
||||
},
|
||||
})
|
||||
|
||||
// add network scan route to api
|
||||
e.Router.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/upsnap/scan",
|
||||
Handler: HandlerScan,
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.ActivityLogger(App),
|
||||
},
|
||||
})
|
||||
|
||||
// import environment and set settings
|
||||
if err := importSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// reset device states and run ping cronjob
|
||||
if err := resetDeviceStates(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// run ping cronjob
|
||||
go cronjobs.RunCron(App)
|
||||
|
||||
// add event hook before starting server.
|
||||
// using this outside App.OnBeforeServe() would not work
|
||||
App.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
|
||||
if e.Model.TableName() == "settings" {
|
||||
for _, job := range cronjobs.Jobs.Entries() {
|
||||
cronjobs.Jobs.Remove(job.ID)
|
||||
}
|
||||
go cronjobs.RunCron(App)
|
||||
} else {
|
||||
refreshDeviceList()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// refresh the device list on database events
|
||||
App.OnModelAfterCreate().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 importSettings() error {
|
||||
// get first settings record
|
||||
settingsRecords, err := App.Dao().FindRecordsByExpr("settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCollection, err := App.Dao().FindCollectionByNameOrId("settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings := models.NewRecord(settingsCollection)
|
||||
if len(settingsRecords) > 0 {
|
||||
settings = settingsRecords[0]
|
||||
}
|
||||
|
||||
// set ping interval and notification settings. priority:
|
||||
// 1st: env var
|
||||
// 2nd: database entry
|
||||
// 3rd: default values
|
||||
interval := "@every 3s"
|
||||
if settings.GetString("interval") != "" {
|
||||
interval = settings.GetString("interval")
|
||||
}
|
||||
if os.Getenv("UPSNAP_INTERVAL") != "" {
|
||||
interval = os.Getenv("UPSNAP_INTERVAL")
|
||||
}
|
||||
notifications := true
|
||||
if settings.GetBool("notifications") {
|
||||
notifications = settings.GetBool("notifications")
|
||||
}
|
||||
if os.Getenv("UPSNAP_NOTIFICATIONS") != "" {
|
||||
notificationsParsed, err := strconv.ParseBool(os.Getenv("UPSNAP_NOTIFICATIONS"))
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
notifications = notificationsParsed
|
||||
}
|
||||
}
|
||||
|
||||
// save settings to db
|
||||
settings.Set("interval", interval)
|
||||
settings.Set("notifications", notifications)
|
||||
if err := App.Dao().SaveRecord(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info.Println("Ping interval set to", interval)
|
||||
logger.Info.Println("Notifications set to", notifications)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetDeviceStates() error {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshDeviceList() {
|
||||
var err error
|
||||
cronjobs.Devices, err = App.Dao().FindRecordsByExpr("devices")
|
||||
if err != nil {
|
||||
logger.Error.Println(err)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -1,38 +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=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)
|
||||
#- PAGE_TITLE=Custom Title # optional, set a custom page title (default: UpSnap)
|
||||
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
|
||||
13
frontend/.eslintignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.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
|
||||
15
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['eslint:recommended', 'prettier'],
|
||||
plugins: ['svelte3'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
||||
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
13
frontend/.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.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
|
||||
9
frontend/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
38
frontend/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^1.0.5",
|
||||
"@sveltejs/kit": "^1.2.2",
|
||||
"eslint": "^8.32.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.3",
|
||||
"prettier-plugin-svelte": "^2.9.0",
|
||||
"svelte": "^3.55.1",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"vite": "^4.0.4"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"bootstrap": "5.3.0-alpha1",
|
||||
"date-fns": "^2.29.3",
|
||||
"pocketbase": "^0.10.1",
|
||||
"sass": "^1.57.1",
|
||||
"svelte-fa": "^3.0.3"
|
||||
}
|
||||
}
|
||||
1512
frontend/pnpm-lock.yaml
generated
Normal file
13
frontend/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-100">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>UpSnap</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="h-100">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
96
frontend/src/components/DeviceCard.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script>
|
||||
import { dev } from '$app/environment';
|
||||
import { parseISO, formatDistance } from 'date-fns';
|
||||
import { faPowerOff, faEllipsisVertical, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import Fa from 'svelte-fa';
|
||||
|
||||
export let device;
|
||||
export let now;
|
||||
|
||||
function shutdown() {
|
||||
fetch(`${dev ? 'http://127.0.0.1:8090' : ''}/api/upsnap/shutdown/${device.id}`);
|
||||
}
|
||||
|
||||
function wake() {
|
||||
fetch(`${dev ? 'http://127.0.0.1:8090' : ''}/api/upsnap/wake/${device.id}`);
|
||||
}
|
||||
|
||||
function sortPorts(a, b) {
|
||||
return a.number - b.number;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3 g-4">
|
||||
<div
|
||||
class="card border-0 rounded-5 px-3 py-2 shadow-sm"
|
||||
class:offline={device.status == 'offline' ? true : false}
|
||||
class:online={device.status == 'online' ? true : false}
|
||||
class:pending={device.status == 'pending' ? true : false}
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-auto me-auto">
|
||||
{#if device.status === 'offline'}
|
||||
<span class="text-danger" on:click={() => wake()} on:keydown={() => wake()}>
|
||||
<div role="button">
|
||||
<Fa icon={faPowerOff} class="fs-4 power-hover" />
|
||||
</div>
|
||||
</span>
|
||||
{:else if device.status === 'online'}
|
||||
{#if device.shutdown_cmd !== ''}
|
||||
<span class="text-success" on:click={() => shutdown()} on:keydown={() => shutdown()}>
|
||||
<div role="button">
|
||||
<Fa icon={faPowerOff} class="fs-4 power-hover" />
|
||||
</div>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-success">
|
||||
<div>
|
||||
<Fa icon={faPowerOff} class="fs-4" />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
{:else if device.status === 'pending'}
|
||||
<div
|
||||
class="spinner-border text-warning"
|
||||
style="width: 23px;height: 23px;"
|
||||
role="status"
|
||||
>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-auto fs-5">
|
||||
<a href="/device/{device.id}" class="text-reset text-center ellipsis power-hover">
|
||||
<Fa icon={faEllipsisVertical} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{#if device.link}
|
||||
<p class="m-0 fw-bold fs-5">
|
||||
<a class="text-reset" target="_blank" rel="noreferrer" href={device.link}>{device.name}</a
|
||||
>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="m-0 fw-bold fs-5">{device.name}</p>
|
||||
{/if}
|
||||
<p class="text-muted mb-2">{device.ip}</p>
|
||||
{#if device?.expand?.ports}
|
||||
<div class="mb-2">
|
||||
{#each device.expand.ports.sort(sortPorts) as port}
|
||||
<p class="m-0">
|
||||
<Fa icon={faCircle} class="me-1 {port.status ? 'port-up' : 'port-down'}" />{port.name}
|
||||
<span class="text-muted">({port.number})</span>
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-muted m-0">
|
||||
{formatDistance(parseISO(device.updated), now, {
|
||||
includeSeconds: true,
|
||||
addSuffix: true
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
350
frontend/src/components/DeviceForm.svelte
Normal file
@@ -0,0 +1,350 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { pocketbase } from '@stores/pocketbase';
|
||||
import { faEye, faEyeSlash, faPlus, faTrashCan } from '@fortawesome/free-solid-svg-icons';
|
||||
import Fa from 'svelte-fa';
|
||||
|
||||
export let device;
|
||||
export let mode;
|
||||
|
||||
let pb;
|
||||
let timeout;
|
||||
let button = {
|
||||
state: 'none',
|
||||
error: ''
|
||||
};
|
||||
let deleteButton = {
|
||||
state: 'none',
|
||||
error: ''
|
||||
};
|
||||
let newPort = {
|
||||
name: '',
|
||||
number: null
|
||||
};
|
||||
let passwordShow = false;
|
||||
$: passwordType = passwordShow ? 'text' : 'password';
|
||||
|
||||
onMount(async () => {
|
||||
pocketbase.subscribe((conn) => {
|
||||
pb = conn;
|
||||
});
|
||||
});
|
||||
|
||||
async function addOrUpdateDevice() {
|
||||
button.state = 'waiting';
|
||||
try {
|
||||
// validate password length
|
||||
if (
|
||||
device.password.length != 0 &&
|
||||
device.password.length != 4 &&
|
||||
device.password.length != 6
|
||||
) {
|
||||
throw 'Password must be 0, 4 or 6 characters long';
|
||||
}
|
||||
|
||||
// add ports not in db
|
||||
for (let i = 0; i < device.expand.ports.length; i++) {
|
||||
const port = device.expand.ports[i];
|
||||
if (!port.id) {
|
||||
const result = await pb.collection('ports').create(port);
|
||||
device.ports = [...device.ports, result.id];
|
||||
}
|
||||
}
|
||||
|
||||
// create or update device
|
||||
if (mode === 'add') {
|
||||
await pb.collection('devices').create(device);
|
||||
} else {
|
||||
await pb.collection('devices').update(device.id);
|
||||
}
|
||||
|
||||
// show button with timeout
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
button.state = 'none';
|
||||
}, 3000);
|
||||
button.state = 'success';
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
setTimeout(() => {
|
||||
button.error = '';
|
||||
button.state = 'none';
|
||||
}, 3000);
|
||||
button.error = error;
|
||||
button.state = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDevice() {
|
||||
deleteButton.state = 'waiting';
|
||||
try {
|
||||
device.ports.forEach(async (port) => {
|
||||
await pb.collection('ports').delete(port);
|
||||
});
|
||||
await pb.collection('devices').delete(device.id);
|
||||
goto('/');
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
deleteButton.error = '';
|
||||
deleteButton.state = 'none';
|
||||
}, 3000);
|
||||
deleteButton.error = error;
|
||||
deleteButton.state = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePort(idx) {
|
||||
// delete port from db if it has an id
|
||||
// ports with id exist in db, ports without id are not created yet
|
||||
const port = device.expand.ports[idx];
|
||||
if (port.id) {
|
||||
await pb.collection('ports').delete(port.id);
|
||||
const i = device.ports.indexOf(port.id);
|
||||
if (i !== -1) {
|
||||
device.ports.splice(i, 1);
|
||||
device.ports = device.ports;
|
||||
}
|
||||
}
|
||||
device.expand.ports.splice(idx, 1);
|
||||
device.expand.ports = device.expand.ports;
|
||||
}
|
||||
|
||||
function addPort() {
|
||||
device.expand.ports = [...device.expand.ports, JSON.parse(JSON.stringify(newPort))];
|
||||
newPort.name = '';
|
||||
newPort.number = null;
|
||||
}
|
||||
|
||||
function onPasswordInput(event) {
|
||||
device.password = event.target.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="m-0 mt-4 p-4 shadow-sm">
|
||||
<h3 class="mb-3">{mode === 'add' ? 'Add new device' : device.name}</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form on:submit|preventDefault={addOrUpdateDevice}>
|
||||
<h5>Required:</h5>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">Name</span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Office Pc"
|
||||
aria-label="Name"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
required
|
||||
bind:value={device.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">IP</span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="192.168.1.34"
|
||||
aria-label="IP"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
required
|
||||
bind:value={device.ip}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">MAC</span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="aa:bb:cc:dd:ee:ff"
|
||||
aria-label="MAC"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
required
|
||||
bind:value={device.mac}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Netmask</span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Most likely 255.255.255.0 or 255.255.0.0"
|
||||
aria-label="Netmask"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
required
|
||||
bind:value={device.netmask}
|
||||
/>
|
||||
</div>
|
||||
<h5 class="mt-4">Optional:</h5>
|
||||
{#if device?.expand?.ports}
|
||||
{#each device?.expand?.ports as port, idx}
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">Port</span>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Name"
|
||||
class="form-control"
|
||||
placeholder="Name"
|
||||
bind:value={port.name}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
max="65535"
|
||||
aria-label="Number"
|
||||
class="form-control"
|
||||
placeholder="Number"
|
||||
bind:value={port.number}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
on:click={() => deletePort(idx)}
|
||||
>
|
||||
<Fa icon={faTrashCan} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Port</span>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Name"
|
||||
class="form-control"
|
||||
placeholder="Name"
|
||||
bind:value={newPort.name}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
max="65535"
|
||||
aria-label="Number"
|
||||
class="form-control"
|
||||
placeholder="Number"
|
||||
bind:value={newPort.number}
|
||||
/>
|
||||
<button type="button" class="btn btn-outline-secondary" on:click={() => addPort()}>
|
||||
<Fa icon={faPlus} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Link</span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Clickable link on device card"
|
||||
aria-label="Link"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="url"
|
||||
bind:value={device.link}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">Wake Cron<sup>(1)</sup></span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Automatically wake device with cron"
|
||||
aria-label="Wake Cron"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
bind:value={device.wake_cron}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">Shutdown Cron<sup>(1)</sup></span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Automatically shutdown device with cron"
|
||||
aria-label="Shutdown Cron"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
bind:value={device.shutdown_cron}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Shutdown Cmd<sup>(2)</sup></span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Command to shutdown device"
|
||||
aria-label="Shutdown Cmd"
|
||||
aria-describedby="addon-wrapping"
|
||||
type="text"
|
||||
bind:value={device.shutdown_cmd}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Password<sup>(3)</sup></span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="BIOS password for wol"
|
||||
aria-label="IP"
|
||||
aria-describedby="addon-wrapping"
|
||||
type={passwordType}
|
||||
value={device.password}
|
||||
maxlength="6"
|
||||
on:input={onPasswordInput}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
on:click={() => (passwordShow = !passwordShow)}
|
||||
>
|
||||
<Fa icon={passwordShow ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-secondary"
|
||||
class:btn-success={button.state === 'success' ? true : false}
|
||||
class:btn-warning={button.state === 'waiting' ? true : false}
|
||||
class:btn-danger={button.state === 'failed' ? true : false}
|
||||
disabled={button.state !== 'none' ? true : false}
|
||||
>
|
||||
{#if button.state === 'none'}
|
||||
{mode === 'add' ? 'Add device' : 'Save device'}
|
||||
{:else if button.state === 'success'}
|
||||
{mode === 'add' ? 'Added successfully' : 'Saved successfully'}
|
||||
{:else if button.state === 'waiting'}
|
||||
Waiting
|
||||
{:else if button.state === 'failed'}
|
||||
Failed: {button.error}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex align-items-start flex-column">
|
||||
<div class="callout callout-info mb-auto">
|
||||
<h5>Optional:</h5>
|
||||
<p class="m-0">(1) Same cron syntax as for ping interval.</p>
|
||||
<p class="m-0">(2) Shell command to be executed. e.g.:</p>
|
||||
<ul>
|
||||
<li>Windows: "net rpc shutdown -I 192.168.1.13 -U test%test"</li>
|
||||
<li>
|
||||
Linux: "sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo
|
||||
shutdown'"
|
||||
</li>
|
||||
</ul>
|
||||
<p class="m-0">
|
||||
(3) Some network cards have the option to set a password for magic packets, also called
|
||||
"SecureON". Password can only be 0, 4 or 6 characters in length.
|
||||
</p>
|
||||
</div>
|
||||
{#if mode === 'edit'}
|
||||
<div class="text-end mt-3 align-self-end">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
on:click={() => deleteDevice()}
|
||||
on:keydown={() => deleteDevice()}
|
||||
>
|
||||
{#if deleteButton.state === 'none'}
|
||||
Delete device
|
||||
{:else if deleteButton.state === 'waiting'}
|
||||
Waiting
|
||||
{:else if deleteButton.state === 'failed'}
|
||||
Failed: {deleteButton.error}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
76
frontend/src/components/Navbar.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { theme } from '@stores/theme';
|
||||
import { faSun, faMoon, faCircleHalfStroke, faBrush } from '@fortawesome/free-solid-svg-icons';
|
||||
import Fa from 'svelte-fa';
|
||||
</script>
|
||||
|
||||
<nav class="navbar navbar-expand">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="/favicon.png" alt="UpSnap" width="30" height="30" />
|
||||
</a>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" class:active={$page.url.pathname === '/' ? true : false} href="/"
|
||||
>Home</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
class:active={$page.url.pathname === '/settings' ? true : false}
|
||||
href="/settings">Settings</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-3">
|
||||
<div
|
||||
class="nav-link dropdown-toggle"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<Fa icon={faBrush} class="me-2" />
|
||||
Theme
|
||||
</div>
|
||||
<ul class="dropdown-menu border-0 p-1">
|
||||
<li>
|
||||
<div
|
||||
class="dropdown-item"
|
||||
class:active={$theme === 'light'}
|
||||
on:click={() => ($theme = 'light')}
|
||||
on:keydown={() => ($theme = 'light')}
|
||||
>
|
||||
<Fa icon={faSun} class="me-2" />
|
||||
Light
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
class="dropdown-item"
|
||||
class:active={$theme === 'dark'}
|
||||
on:click={() => ($theme = 'dark')}
|
||||
on:keydown={() => ($theme = 'dark')}
|
||||
>
|
||||
<Fa icon={faMoon} class="me-2" />
|
||||
Dark
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
class="dropdown-item"
|
||||
class:active={$theme === 'auto'}
|
||||
on:click={() => ($theme = 'auto')}
|
||||
on:keydown={() => ($theme = 'auto')}
|
||||
>
|
||||
<Fa icon={faCircleHalfStroke} class="me-2" />
|
||||
Auto
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
10
frontend/src/components/Transition.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { fly } from 'svelte/transition';
|
||||
export let url;
|
||||
</script>
|
||||
|
||||
{#key url}
|
||||
<div in:fly={{ y: 50, duration: 300 }}>
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
||||
1
frontend/src/routes/+layout.js
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
61
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Navbar from '@components/Navbar.svelte';
|
||||
import Transition from '@components/Transition.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { theme } from '@stores/theme';
|
||||
|
||||
let preferesDark;
|
||||
|
||||
onMount(() => {
|
||||
// import bootstrap js
|
||||
import('bootstrap/js/dist/dropdown');
|
||||
|
||||
// set dark mode
|
||||
preferesDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
preferesDark.addEventListener('change', (e) => {
|
||||
if ($theme === 'auto') {
|
||||
document.documentElement.setAttribute(
|
||||
'data-bs-theme',
|
||||
e.matches ? 'dark' : 'light'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
theme.subscribe((t) => {
|
||||
localStorage.setItem('theme', t);
|
||||
if (t === 'auto') {
|
||||
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
if (document) {
|
||||
preferesDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
let t = localStorage.getItem('theme');
|
||||
if (!t) {
|
||||
localStorage.setItem('theme', 'auto');
|
||||
t = preferesDark;
|
||||
} else if (t === 'auto') {
|
||||
t = preferesDark;
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
}
|
||||
</script>
|
||||
</svelte:head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<Transition url={$page.url}>
|
||||
<slot />
|
||||
</Transition>
|
||||
|
||||
<style lang="scss" global>
|
||||
@import '../scss/main.scss';
|
||||
</style>
|
||||
69
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import DeviceCard from '@components/DeviceCard.svelte';
|
||||
import { pocketbase } from '@stores/pocketbase';
|
||||
|
||||
let devices = {};
|
||||
|
||||
onMount(async () => {
|
||||
let pb;
|
||||
pocketbase.subscribe(conn => {
|
||||
pb = conn;
|
||||
});
|
||||
|
||||
// get all devices in pocketbase
|
||||
const result = await pb.collection('devices').getFullList(200, {
|
||||
expand: 'ports',
|
||||
sort: 'name'
|
||||
});
|
||||
result.forEach((device) => {
|
||||
devices[device.id] = device;
|
||||
});
|
||||
|
||||
// subscribe to database events
|
||||
pb.collection('devices').subscribe('*', async (e) => {
|
||||
if (e.action === 'create') {
|
||||
devices[e.record.id] = e.record;
|
||||
} else if (e.action === 'update') {
|
||||
const device = await pb.collection('devices').getOne(e.record.id, {
|
||||
expand: 'ports'
|
||||
})
|
||||
devices[device.id] = device
|
||||
} else if (e.action === 'delete') {
|
||||
delete devices[e.record.id]
|
||||
}
|
||||
});
|
||||
pb.collection('ports').subscribe('*', async (e) => {
|
||||
if (e.action === 'update') {
|
||||
const device = await pb.collection('devices').getFirstListItem(`ports.id ?= "${e.record.id}"`, {
|
||||
expand: 'ports'
|
||||
})
|
||||
devices[device.id] = device
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// update device date
|
||||
let now = Date.now();
|
||||
let clear;
|
||||
$: {
|
||||
clearInterval(clear);
|
||||
clear = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container text-body-emphasis">
|
||||
{#if Object.keys(devices).length > 0}
|
||||
<div class="row">
|
||||
{#each Object.entries(devices) as [_, device]}
|
||||
<DeviceCard {device} {now} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<h4 class="text-muted">No devices</h4>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
6
frontend/src/routes/device/[id]/+page.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const prerender = true;
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export function load({ params }) {
|
||||
return { params }
|
||||
}
|
||||
27
frontend/src/routes/device/[id]/+page.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { pocketbase } from '@stores/pocketbase';
|
||||
import DeviceForm from '@components/DeviceForm.svelte';
|
||||
|
||||
export let data;
|
||||
const id = data.params.id;
|
||||
let pb;
|
||||
let device = {
|
||||
id: id
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
pocketbase.subscribe((conn) => {
|
||||
pb = conn;
|
||||
});
|
||||
|
||||
const result = await pb.collection('devices').getOne(id, {
|
||||
expand: 'ports'
|
||||
});
|
||||
device = result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<DeviceForm bind:device mode="edit" />
|
||||
</div>
|
||||