Merge pull request #36 from seriousm4x/v3

merge v3
This commit is contained in:
seriousm4x
2023-01-27 01:38:27 +01:00
committed by GitHub
107 changed files with 6867 additions and 6737 deletions

View File

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

View File

@@ -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
View File

@@ -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

View File

@@ -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"]

View File

@@ -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 |
| -------------------- | --------------------- |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/index-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/index-light.png) |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/device-settings-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/device-settings-light.png) |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/settings-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/settings-light.png) |
| ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/add-device-dark.png) | ![](https://raw.githubusercontent.com/seriousm4x/upsnap/master/assets/add-device-light.png) |
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

View File

@@ -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))
})

View File

@@ -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()

View File

@@ -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"

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -1,11 +0,0 @@
celery[redis]
channels
channels-redis==3.4.1
django
django-celery-beat
gunicorn
mysqlclient
psycopg2-binary
uvicorn[standard]
wakeonlan
whitenoise

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class WolConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'wol'

View File

@@ -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)

View File

@@ -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

View File

@@ -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)),
],
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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)),
],
),
]

View File

@@ -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),
),
]

View File

@@ -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')),
],
),
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View File

@@ -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)

View File

@@ -1,7 +0,0 @@
from django.urls import path
from wol.consumers import WSConsumer
ws_urlpatterns = [
path("wol/", WSConsumer.as_asgi())
]

View File

@@ -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
}})

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,4 +0,0 @@
/node_modules/
/public/build/
.DS_Store

View File

@@ -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
```

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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
}
};

View File

@@ -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.")
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,8 +0,0 @@
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;

View File

@@ -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
}

View File

@@ -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;
}

View File

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

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

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

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

9
backend/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"github.com/seriousm4x/upsnap/backend/pb"
)
func main() {
pb.StartPocketBase()
}

View 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)
})
}

View 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)
})
}

View 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)
})
}

View 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)
})
}

View 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
}

View 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
}

View File

@@ -0,0 +1,26 @@
package networking
import (
"time"
"github.com/pocketbase/pocketbase/models"
"github.com/seriousm4x/upsnap/backend/logger"
)
func WakeDevice(device *models.Record) bool {
err := SendMagicPacket(device)
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
View 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
View 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)
}
}

View File

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

View File

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

View File

@@ -1,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
View 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
View 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
View File

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

13
frontend/.prettierignore Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

13
frontend/src/app.html Normal file
View 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>

View 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>

View 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>

View 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>

View 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}

View File

@@ -0,0 +1 @@
export const prerender = true;

View 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>

View 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>

View File

@@ -0,0 +1,6 @@
export const prerender = true;
/** @type {import('./$types').PageLoad} */
export function load({ params }) {
return { params }
}

View 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>

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