mirror of
https://github.com/seriousm4x/UpSnap.git
synced 2026-04-05 08:54:03 -04:00
sveltekit and go fiber+gorm
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
.git
|
||||
node_modules
|
||||
/app/frontend/public/build
|
||||
/app/frontend/scripts
|
||||
app/frontend/node_modules
|
||||
app/frontend/build
|
||||
**/db.sqlite3
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -292,3 +292,4 @@ staticfiles
|
||||
# custom
|
||||
docker-compose.yml
|
||||
db/
|
||||
*.db
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,36 +0,0 @@
|
||||
FROM debian:bullseye-slim as base
|
||||
|
||||
FROM base as python-build
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /python-build
|
||||
RUN apt-get update &&\
|
||||
apt-get install -y --no-install-recommends build-essential libffi-dev libssl-dev cargo python3 python3-dev python3-pip python3-venv default-libmysqlclient-dev libpq-dev &&\
|
||||
python3 -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY app/backend/requirements.txt .
|
||||
RUN python3 -m pip install --no-cache-dir --upgrade pip wheel &&\
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM node:17-bullseye-slim as node-build
|
||||
WORKDIR /node-build
|
||||
COPY app/frontend/package*.json ./
|
||||
COPY app/frontend/src ./src
|
||||
COPY app/frontend/public ./public
|
||||
COPY app/frontend/rollup.config.js ./
|
||||
|
||||
FROM base
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update &&\
|
||||
apt-get install -y --no-install-recommends default-mysql-client nodejs npm iputils-ping nmap samba-common-bin openssh-client sshpass curl &&\
|
||||
apt-get clean &&\
|
||||
rm -rf /var/lib/{apt,dpkg,cache,log}/
|
||||
COPY --from=python-build /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY --from=node-build /node-build ./frontend
|
||||
COPY app/backend ./backend
|
||||
COPY app/run.sh ./
|
||||
|
||||
CMD ["./run.sh"]
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
ASGI config for backend project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from wol.routing import ws_urlpatterns
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AuthMiddlewareStack(URLRouter(ws_urlpatterns))
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
|
||||
app = Celery("dango_wol")
|
||||
app.conf.timezone = os.getenv("DJANGO_TIME_ZONE", "UTC")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
Django settings for backend project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.0.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', get_random_secret_key())
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'channels',
|
||||
'django_celery_beat',
|
||||
'wol'
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'backend.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||
WSGI_APPLICATION = 'backend.wsgi.application'
|
||||
ASGI_APPLICATION = 'backend.asgi.application'
|
||||
CELERY_BROKER_URL = f"redis://{os.getenv('REDIS_HOST', '127.0.0.1')}:{os.getenv('REDIS_PORT', 6379)}"
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
if os.getenv("DB_TYPE") == "postgres":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": os.getenv("DB_NAME", "upsnap"),
|
||||
"USER": os.getenv("DB_USER", "upsnap"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", "upsnap"),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", 5432),
|
||||
"OPTIONS": {
|
||||
"connect_timeout": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
elif os.getenv("DB_TYPE") == "mysql":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "upsnap"),
|
||||
"USER": os.getenv("DB_USER", "upsnap"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", "upsnap"),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", 3306),
|
||||
"OPTIONS": {
|
||||
"connect_timeout": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
elif os.getenv("DB_TYPE") == "sqlite":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": "/app/backend/db/db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(os.getenv("REDIS_HOST", "127.0.0.1"), os.getenv("REDIS_PORT", 6379))],
|
||||
"capacity": 1000,
|
||||
"expiry": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = os.getenv("DJANGO_LANGUAGE_CODE", "en")
|
||||
|
||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
VERSION = "v2.3.1"
|
||||
@@ -1,23 +0,0 @@
|
||||
"""backend URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/4.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls)
|
||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
WSGI config for backend project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,11 +0,0 @@
|
||||
celery[redis]
|
||||
channels
|
||||
channels-redis==3.4.1
|
||||
django
|
||||
django-celery-beat
|
||||
gunicorn
|
||||
mysqlclient
|
||||
psycopg2-binary
|
||||
uvicorn[standard]
|
||||
wakeonlan
|
||||
whitenoise
|
||||
@@ -1,51 +0,0 @@
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask, PeriodicTasks
|
||||
|
||||
from wol.models import Settings, Websocket
|
||||
|
||||
# create superuser
|
||||
if os.getenv("DJANGO_SUPERUSER_USER") and os.getenv("DJANGO_SUPERUSER_PASSWORD"):
|
||||
if not User.objects.filter(username=os.getenv("DJANGO_SUPERUSER_USER")).exists():
|
||||
User.objects.create_superuser(os.getenv("DJANGO_SUPERUSER_USER"), password=os.getenv("DJANGO_SUPERUSER_PASSWORD"))
|
||||
else:
|
||||
print('Django user exists')
|
||||
|
||||
# reset visitors
|
||||
[i.delete() for i in Websocket.objects.all()]
|
||||
Websocket.objects.create(visitors=0)
|
||||
|
||||
# ping interval
|
||||
if os.environ.get("PING_INTERVAL"):
|
||||
if int(os.environ.get("PING_INTERVAL")) < 5:
|
||||
print("Ping interval lower than 5 seconds is not recommended. Please use an interval of 5 seconds or higher. Automatically set to 5 seconds.")
|
||||
ping_interval = 5
|
||||
else:
|
||||
ping_interval = int(os.environ.get("PING_INTERVAL"))
|
||||
else:
|
||||
ping_interval = 5
|
||||
Settings.objects.update_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
"interval": ping_interval
|
||||
}
|
||||
)
|
||||
|
||||
# register device ping task
|
||||
schedule, created = IntervalSchedule.objects.get_or_create(
|
||||
every=ping_interval,
|
||||
period=IntervalSchedule.SECONDS,
|
||||
)
|
||||
if created:
|
||||
PeriodicTask.objects.create(
|
||||
interval=schedule,
|
||||
name="Ping all devices",
|
||||
task="wol.tasks.ping_all_devices"
|
||||
)
|
||||
|
||||
# reset last run to fix time zone changes
|
||||
# https://django-celery-beat.readthedocs.io/en/latest/#important-warning-about-time-zones
|
||||
PeriodicTask.objects.update(last_run_at=None)
|
||||
for task in PeriodicTask.objects.all():
|
||||
PeriodicTasks.changed(task)
|
||||
@@ -1,25 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from wol.models import Device, Port, Settings
|
||||
|
||||
|
||||
class DeviceAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "ip", "mac", "netmask", "ports"]
|
||||
search_fields = ["name", "ip", "mac"]
|
||||
list_filter = ["name", "netmask"]
|
||||
|
||||
def ports(self, obj):
|
||||
return ", ".join([p.name for p in obj.port.all()])
|
||||
|
||||
class PortAdmin(admin.ModelAdmin):
|
||||
list_display = ["number", "name"]
|
||||
search_fields = ["number", "name"]
|
||||
list_filter = ["number", "name"]
|
||||
|
||||
class SettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ["sort_by", "scan_address", "interval"]
|
||||
|
||||
|
||||
admin.site.register(Device, DeviceAdmin)
|
||||
admin.site.register(Port, PortAdmin)
|
||||
admin.site.register(Settings, SettingsAdmin)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WolConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'wol'
|
||||
@@ -1,12 +0,0 @@
|
||||
import ipaddress
|
||||
import wakeonlan
|
||||
import subprocess
|
||||
|
||||
|
||||
def wake(mac, ip, netmask):
|
||||
subnet = ipaddress.ip_network(
|
||||
f"{ip}/{netmask}", strict=False).broadcast_address
|
||||
wakeonlan.send_magic_packet(mac, ip_address=str(subnet))
|
||||
|
||||
def shutdown(command):
|
||||
subprocess.run(command, shell=True)
|
||||
@@ -1,337 +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 Port.objects.all().order_by("number"):
|
||||
data["ports"].append({
|
||||
"number": p.number,
|
||||
"name": p.name,
|
||||
"checked": False,
|
||||
"open": False
|
||||
})
|
||||
for action in ["wake", "shutdown"]:
|
||||
try:
|
||||
task = PeriodicTask.objects.filter(
|
||||
name=f"{data['name']}-{action}",
|
||||
task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
|
||||
if task:
|
||||
wake = CrontabSchedule.objects.get(id=task.crontab_id)
|
||||
data[action]["enabled"] = task.enabled
|
||||
data[action]["cron"] = " ".join(
|
||||
[wake.minute, wake.hour, wake.day_of_month, wake.month_of_year, wake.day_of_week])
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
d.append(data)
|
||||
return d
|
||||
|
||||
@database_sync_to_async
|
||||
def delete_device(self, id):
|
||||
dev = Device.objects.get(id=id)
|
||||
dev.delete()
|
||||
|
||||
@database_sync_to_async
|
||||
def update_device(self, data):
|
||||
obj, _ = Device.objects.update_or_create(
|
||||
mac=data["mac"],
|
||||
defaults={
|
||||
"name": data["name"],
|
||||
"ip": data["ip"],
|
||||
"netmask": data["netmask"],
|
||||
"link": data["link"] if data.get("link") else "",
|
||||
"shutdown_cmd": data["shutdown"]["command"] if data.get("shutdown") else ""
|
||||
}
|
||||
)
|
||||
if data.get("ports"):
|
||||
for port in data.get("ports"):
|
||||
if port["checked"]:
|
||||
p, _ = Port.objects.get_or_create(
|
||||
number=port["number"], name=port["name"])
|
||||
obj.port.add(p)
|
||||
else:
|
||||
p = Port.objects.filter(number=port["number"]).first()
|
||||
if p and p in obj.port.all():
|
||||
obj.port.remove(p)
|
||||
|
||||
for action in ["wake", "shutdown"]:
|
||||
if data.get(action):
|
||||
if data[action]["enabled"]:
|
||||
cron = data[action]["cron"].strip().split(" ")
|
||||
if not len(cron) == 5:
|
||||
return
|
||||
minute, hour, dom, month, dow = cron
|
||||
schedule, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=dow,
|
||||
day_of_month=dom,
|
||||
month_of_year=month,
|
||||
timezone=pytz.timezone(
|
||||
os.getenv("DJANGO_TIME_ZONE", "UTC"))
|
||||
)
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=f"{data['name']}-{action}",
|
||||
defaults={
|
||||
"crontab": schedule,
|
||||
"task": f"wol.tasks.scheduled_{action}",
|
||||
"args": json.dumps([data["id"]]),
|
||||
"enabled": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
for task in PeriodicTask.objects.filter(name=f"{data['name']}-{action}", task=f"wol.tasks.scheduled_{action}"):
|
||||
task.enabled = False
|
||||
task.save()
|
||||
|
||||
@database_sync_to_async
|
||||
def update_port(self, data):
|
||||
if data.get("name"):
|
||||
Port.objects.update_or_create(
|
||||
number=data["number"],
|
||||
defaults={
|
||||
"name": data["name"]
|
||||
}
|
||||
)
|
||||
else:
|
||||
Port.objects.filter(number=data["number"]).delete()
|
||||
|
||||
@database_sync_to_async
|
||||
def get_settings(self):
|
||||
conf = Settings.objects.get(id=1)
|
||||
data = {
|
||||
"discovery": conf.scan_address,
|
||||
"interval": conf.interval,
|
||||
"scan_network": [],
|
||||
"notifications": conf.notifications
|
||||
}
|
||||
return data
|
||||
|
||||
@database_sync_to_async
|
||||
def update_settings(self, data):
|
||||
if data["interval"] > 5:
|
||||
data["interval"] = 5
|
||||
Settings.objects.update_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
"scan_address": data["discovery"],
|
||||
"interval": data["interval"],
|
||||
"notifications": data["notifications"]
|
||||
}
|
||||
)
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=data["interval"],
|
||||
period=IntervalSchedule.SECONDS,
|
||||
)
|
||||
PeriodicTask.objects.update_or_create(
|
||||
task="wol.tasks.ping_all_devices",
|
||||
defaults={
|
||||
"interval": schedule
|
||||
}
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def scan_network(self):
|
||||
data = []
|
||||
conf = Settings.objects.get(id=1)
|
||||
netmask = str(ipaddress.ip_network(conf.scan_address).netmask)
|
||||
|
||||
if not conf.scan_address:
|
||||
return
|
||||
|
||||
p = subprocess.Popen(
|
||||
["nmap", os.getenv("NMAP_ARGS", "-sP"), conf.scan_address], stdout=subprocess.PIPE)
|
||||
out = p.communicate()[0].decode("utf-8")
|
||||
ip_line = "Nmap scan report for"
|
||||
mac_line = "MAC Address:"
|
||||
|
||||
for line in out.splitlines():
|
||||
if line.startswith(ip_line):
|
||||
line_splitted = line.split()
|
||||
if len(line_splitted) == 6:
|
||||
name = line_splitted[4]
|
||||
ip = line_splitted[5]
|
||||
ip = ip.replace("(", "")
|
||||
ip = ip.replace(")", "")
|
||||
else:
|
||||
name = "Unknown"
|
||||
ip = line_splitted[4]
|
||||
elif line.startswith(mac_line):
|
||||
line_splitted = line.split()
|
||||
mac = line_splitted[2]
|
||||
data.append({
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"netmask": netmask,
|
||||
"mac": mac,
|
||||
"link": "",
|
||||
"ports": [],
|
||||
"wake": {
|
||||
"enabled": False,
|
||||
"cron": ""
|
||||
},
|
||||
"shutdown": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"command": ""
|
||||
}
|
||||
})
|
||||
return data
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-15 19:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.SlugField(max_length=100)),
|
||||
('ip', models.GenericIPAddressField()),
|
||||
('mac', models.SlugField(max_length=17)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-15 19:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='mac',
|
||||
field=models.CharField(max_length=17),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-15 22:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0002_auto_20201115_2043'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-16 02:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0003_device_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-18 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0004_remove_device_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='netmask',
|
||||
field=models.CharField(default='255.255.255.0', max_length=15),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-22 20:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0005_device_netmask'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Websocket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('visitors', models.PositiveSmallIntegerField(default=0)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-23 16:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0006_websocket'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='scheduled_wake',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-27 16:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0007_device_scheduled_wake'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Settings',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ping_interval', models.PositiveSmallIntegerField(default=5)),
|
||||
('enable_notifications', models.BooleanField(default=True)),
|
||||
('enable_console_logging', models.BooleanField(default=True)),
|
||||
('sort_by', models.SlugField(default='name')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-27 19:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0008_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='settings',
|
||||
name='ping_interval',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-28 19:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0009_remove_settings_ping_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='settings',
|
||||
name='enable_console_logging',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-28 20:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0010_alter_settings_enable_console_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='settings',
|
||||
name='scan_address',
|
||||
field=models.GenericIPAddressField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.7 on 2021-09-28 23:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0011_settings_scan_address'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='name',
|
||||
field=models.SlugField(default='Unknown', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -1,64 +0,0 @@
|
||||
# Generated by Django 3.2.12 on 2022-03-12 10:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wol', '0012_alter_device_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Port',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('number', models.PositiveIntegerField()),
|
||||
('name', models.SlugField()),
|
||||
],
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='settings',
|
||||
old_name='enable_notifications',
|
||||
new_name='notifications',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='device',
|
||||
name='scheduled_wake',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='settings',
|
||||
name='enable_console_logging',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='shutdown_cmd',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settings',
|
||||
name='interval',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='settings',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='websocket',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='port',
|
||||
field=models.ManyToManyField(blank=True, to='wol.Port'),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Port(models.Model):
|
||||
number = models.PositiveIntegerField()
|
||||
name = models.SlugField()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
class Device(models.Model):
|
||||
name = models.SlugField(default="Unknown", max_length=100)
|
||||
ip = models.GenericIPAddressField()
|
||||
mac = models.CharField(max_length=17)
|
||||
netmask = models.CharField(max_length=15, default="255.255.255.0", blank=False, null=False)
|
||||
link = models.URLField(blank=True, null=True)
|
||||
port = models.ManyToManyField(Port, blank=True)
|
||||
shutdown_cmd = models.TextField(null=True, blank=True)
|
||||
|
||||
class Websocket(models.Model):
|
||||
visitors = models.PositiveSmallIntegerField(blank=False, null=False, default=0)
|
||||
|
||||
class Settings(models.Model):
|
||||
sort_by = models.SlugField(default="name")
|
||||
scan_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
interval = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
notifications = models.BooleanField(default=True)
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from wol.consumers import WSConsumer
|
||||
|
||||
ws_urlpatterns = [
|
||||
path("wol/", WSConsumer.as_asgi())
|
||||
]
|
||||
@@ -1,148 +0,0 @@
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery import shared_task
|
||||
from channels.layers import get_channel_layer
|
||||
from django.db import connection
|
||||
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
||||
|
||||
from wol.commands import shutdown, wake
|
||||
from wol.models import Device, Port, Websocket
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
|
||||
|
||||
class WolDevice:
|
||||
def ping_device(self, ip):
|
||||
try:
|
||||
subprocess.check_output(["ping", "-c", "1", "-W", "0.5", ip])
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def check_port(self, ip, port):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.5)
|
||||
if sock.connect_ex((ip, port)) == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def start(self, dev):
|
||||
data = {
|
||||
"id": dev.id,
|
||||
"name": dev.name,
|
||||
"ip": dev.ip,
|
||||
"mac": dev.mac,
|
||||
"netmask": dev.netmask,
|
||||
"up": False,
|
||||
"link": dev.link,
|
||||
"ports": [],
|
||||
"wake": {
|
||||
"enabled": False,
|
||||
"cron": ""
|
||||
},
|
||||
"shutdown": {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"command": dev.shutdown_cmd
|
||||
}
|
||||
}
|
||||
|
||||
# add ports
|
||||
for p in Port.objects.all().order_by("number"):
|
||||
data["ports"].append({
|
||||
"number": p.number,
|
||||
"name": p.name,
|
||||
"checked": True if p in dev.port.all() else False,
|
||||
"open": False
|
||||
})
|
||||
|
||||
# set status for device and ports
|
||||
if self.ping_device(dev.ip):
|
||||
data["up"] = True
|
||||
for port in dev.port.all():
|
||||
index = next(i for i, d in enumerate(
|
||||
data["ports"]) if d["number"] == port.number)
|
||||
if self.check_port(dev.ip, port.number):
|
||||
data["ports"][index]["checked"] = True
|
||||
data["ports"][index]["open"] = True
|
||||
else:
|
||||
data["ports"][index]["checked"] = True
|
||||
data["ports"][index]["open"] = False
|
||||
|
||||
# set cron for wake and shutdown
|
||||
for action in ["wake", "shutdown"]:
|
||||
try:
|
||||
task = PeriodicTask.objects.filter(
|
||||
name=f"{data['name']}-{action}",
|
||||
task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
|
||||
if task:
|
||||
wake = CrontabSchedule.objects.get(id=task.crontab_id)
|
||||
data[action]["enabled"] = task.enabled
|
||||
data[action]["cron"] = " ".join(
|
||||
[wake.minute, wake.hour, wake.day_of_week, wake.day_of_month, wake.month_of_year])
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"wol", {"type": "send_group", "message": {
|
||||
"type": "status",
|
||||
"message": data
|
||||
}})
|
||||
|
||||
connection.close()
|
||||
|
||||
|
||||
@shared_task
|
||||
def ping_all_devices():
|
||||
if Websocket.objects.first().visitors == 0:
|
||||
return
|
||||
|
||||
devices = Device.objects.all()
|
||||
|
||||
for dev in devices:
|
||||
d = WolDevice()
|
||||
t = threading.Thread(target=d.start, args=(dev,))
|
||||
t.start()
|
||||
|
||||
|
||||
@shared_task
|
||||
def scheduled_wake(id):
|
||||
try:
|
||||
device = Device.objects.get(id=id)
|
||||
except Device.DoesNotExist:
|
||||
for task in PeriodicTask.objects.filter(args=id):
|
||||
task.delete()
|
||||
return
|
||||
|
||||
d = WolDevice()
|
||||
up = d.ping_device(device.ip)
|
||||
if not up:
|
||||
wake(device.mac, device.ip, device.netmask)
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"wol", {"type": "send_group", "message": {
|
||||
"type": "pending",
|
||||
"message": id
|
||||
}})
|
||||
|
||||
|
||||
@shared_task
|
||||
def scheduled_shutdown(id):
|
||||
try:
|
||||
device = Device.objects.get(id=id)
|
||||
except Device.DoesNotExist:
|
||||
for task in PeriodicTask.objects.filter(args=id):
|
||||
task.delete()
|
||||
return
|
||||
|
||||
d = WolDevice()
|
||||
up = d.ping_device(device.ip)
|
||||
if up:
|
||||
shutdown(device.shutdown_cmd)
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
"wol", {"type": "send_group", "message": {
|
||||
"type": "pending",
|
||||
"message": id
|
||||
}})
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
4
app/frontend/.gitignore
vendored
4
app/frontend/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
/node_modules/
|
||||
/public/build/
|
||||
|
||||
.DS_Store
|
||||
@@ -1,109 +0,0 @@
|
||||
*Psst — looking for a more complete solution? Check out [SvelteKit](https://kit.svelte.dev), the official framework for building web applications of all sizes, with a beautiful development experience and flexible filesystem-based routing.*
|
||||
|
||||
*Looking for a shareable component template instead? You can [use SvelteKit for that as well](https://kit.svelte.dev/docs#packaging) or the older [sveltejs/component-template](https://github.com/sveltejs/component-template)*
|
||||
|
||||
---
|
||||
|
||||
# svelte app
|
||||
|
||||
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.
|
||||
|
||||
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
|
||||
|
||||
```bash
|
||||
npx degit sveltejs/template svelte-app
|
||||
cd svelte-app
|
||||
```
|
||||
|
||||
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
Install the dependencies...
|
||||
|
||||
```bash
|
||||
cd svelte-app
|
||||
npm install
|
||||
```
|
||||
|
||||
...then start [Rollup](https://rollupjs.org):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigate to [localhost:8080](http://localhost:8080). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.
|
||||
|
||||
By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`.
|
||||
|
||||
If you're using [Visual Studio Code](https://code.visualstudio.com/) we recommend installing the official extension [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using other editors you may need to install a plugin in order to get syntax highlighting and intellisense.
|
||||
|
||||
## Building and running in production mode
|
||||
|
||||
To create an optimised version of the app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can run the newly built app with `npm run start`. This uses [sirv](https://github.com/lukeed/sirv), which is included in your package.json's `dependencies` so that the app will work when you deploy to platforms like [Heroku](https://heroku.com).
|
||||
|
||||
|
||||
## Single-page app mode
|
||||
|
||||
By default, sirv will only respond to requests that match files in `public`. This is to maximise compatibility with static fileservers, allowing you to deploy your app anywhere.
|
||||
|
||||
If you're building a single-page app (SPA) with multiple routes, sirv needs to be able to respond to requests for *any* path. You can make it so by editing the `"start"` command in package.json:
|
||||
|
||||
```js
|
||||
"start": "sirv public --single"
|
||||
```
|
||||
|
||||
## Using TypeScript
|
||||
|
||||
This template comes with a script to set up a TypeScript development environment, you can run it immediately after cloning the template with:
|
||||
|
||||
```bash
|
||||
node scripts/setupTypeScript.js
|
||||
```
|
||||
|
||||
Or remove the script via:
|
||||
|
||||
```bash
|
||||
rm scripts/setupTypeScript.js
|
||||
```
|
||||
|
||||
If you want to use `baseUrl` or `path` aliases within your `tsconfig`, you need to set up `@rollup/plugin-alias` to tell Rollup to resolve the aliases. For more info, see [this StackOverflow question](https://stackoverflow.com/questions/63427935/setup-tsconfig-path-in-svelte).
|
||||
|
||||
## Deploying to the web
|
||||
|
||||
### With [Vercel](https://vercel.com)
|
||||
|
||||
Install `vercel` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g vercel
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
cd public
|
||||
vercel deploy --name my-project
|
||||
```
|
||||
|
||||
### With [surge](https://surge.sh/)
|
||||
|
||||
Install `surge` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g surge
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
surge public my-project.surge.sh
|
||||
```
|
||||
2872
app/frontend/package-lock.json
generated
2872
app/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "svelte-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"start": "sirv public --no-clear --host"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-node-resolve": "^11.0.0",
|
||||
"rollup": "^2.3.4",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-css-only": "^3.1.0",
|
||||
"rollup-plugin-livereload": "^2.0.0",
|
||||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"rollup-plugin-terser": "^7.0.0",
|
||||
"svelte": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"postcss": "^8.4.6",
|
||||
"sass": "^1.49.9",
|
||||
"sirv-cli": "^2.0.0",
|
||||
"svelte-preprocess": "^4.10.3"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
main.svelte-1tky8bj {
|
||||
text-align: center;
|
||||
padding: 1em;
|
||||
max-width: 240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1.svelte-1tky8bj {
|
||||
color: #ff3e00;
|
||||
text-transform: uppercase;
|
||||
font-size: 4em;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
main.svelte-1tky8bj {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||
|
||||
<link rel='icon' type='image/png' href='favicon.png'>
|
||||
<link rel='stylesheet' href='global.css'>
|
||||
<link rel='stylesheet' href='build/bundle.css'>
|
||||
|
||||
<script defer src='build/bundle.js'></script>
|
||||
<script defer src='build/popper.min.js'></script>
|
||||
<script defer src='build/bootstrap.min.js'></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,103 +0,0 @@
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import livereload from 'rollup-plugin-livereload';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import css from 'rollup-plugin-css-only';
|
||||
import preprocess from 'svelte-preprocess';
|
||||
import copy from 'rollup-plugin-copy';
|
||||
import dotenv from "dotenv";
|
||||
import replace from '@rollup/plugin-replace';
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const production = !process.env.ROLLUP_WATCH;
|
||||
|
||||
function serve() {
|
||||
let server;
|
||||
|
||||
function toExit() {
|
||||
if (server) server.kill(0);
|
||||
}
|
||||
|
||||
return {
|
||||
writeBundle() {
|
||||
if (server) return;
|
||||
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
|
||||
process.on('SIGTERM', toExit);
|
||||
process.on('exit', toExit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
input: 'src/main.js',
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'iife',
|
||||
name: 'app',
|
||||
file: 'public/build/bundle.js'
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
},
|
||||
preprocess: preprocess(),
|
||||
}),
|
||||
replace({
|
||||
BACKEND_PORT: JSON.stringify(process.env.BACKEND_PORT),
|
||||
BACKEND_IS_PROXIED: JSON.stringify(process.env.BACKEND_IS_PROXIED.toLowerCase() === 'true'),
|
||||
PAGE_TITLE: JSON.stringify(process.env.PAGE_TITLE),
|
||||
preventAssignment: true
|
||||
}),
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file - better for performance
|
||||
css({ output: 'bundle.css' }),
|
||||
copy({
|
||||
targets: [
|
||||
{
|
||||
src: [
|
||||
"node_modules/@fortawesome/fontawesome-free/webfonts",
|
||||
"node_modules/bootstrap/dist/js/bootstrap.min.js",
|
||||
"node_modules/bootstrap/dist/js/bootstrap.min.js.map",
|
||||
"node_modules/@popperjs/core/dist/umd/popper.min.js",
|
||||
"node_modules/@popperjs/core/dist/umd/popper.min.js.map"
|
||||
],
|
||||
dest: "public/build/"
|
||||
}
|
||||
]
|
||||
}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration -
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte']
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
// In dev mode, call `npm run start` once
|
||||
// the bundle has been generated
|
||||
!production && serve(),
|
||||
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload('public'),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
production && terser()
|
||||
],
|
||||
watch: {
|
||||
clearScreen: false
|
||||
}
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/** This script modifies the project to support TS code in .svelte files like:
|
||||
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
</script>
|
||||
|
||||
As well as validating the code for CI.
|
||||
*/
|
||||
|
||||
/** To work on this script:
|
||||
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { argv } = require("process")
|
||||
|
||||
const projectRoot = argv[2] || path.join(__dirname, "..")
|
||||
|
||||
// Add deps to pkg.json
|
||||
const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))
|
||||
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
|
||||
"svelte-check": "^2.0.0",
|
||||
"svelte-preprocess": "^4.0.0",
|
||||
"@rollup/plugin-typescript": "^8.0.0",
|
||||
"typescript": "^4.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"@tsconfig/svelte": "^2.0.0"
|
||||
})
|
||||
|
||||
// Add script for checking
|
||||
packageJSON.scripts = Object.assign(packageJSON.scripts, {
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
})
|
||||
|
||||
// Write the package JSON
|
||||
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " "))
|
||||
|
||||
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
|
||||
const beforeMainJSPath = path.join(projectRoot, "src", "main.js")
|
||||
const afterMainTSPath = path.join(projectRoot, "src", "main.ts")
|
||||
fs.renameSync(beforeMainJSPath, afterMainTSPath)
|
||||
|
||||
// Switch the app.svelte file to use TS
|
||||
const appSveltePath = path.join(projectRoot, "src", "App.svelte")
|
||||
let appFile = fs.readFileSync(appSveltePath, "utf8")
|
||||
appFile = appFile.replace("<script>", '<script lang="ts">')
|
||||
appFile = appFile.replace("export let name;", 'export let name: string;')
|
||||
fs.writeFileSync(appSveltePath, appFile)
|
||||
|
||||
// Edit rollup config
|
||||
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
|
||||
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
|
||||
|
||||
// Edit imports
|
||||
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
import typescript from '@rollup/plugin-typescript';`)
|
||||
|
||||
// Replace name of entry point
|
||||
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
|
||||
|
||||
// Add preprocessor
|
||||
rollupConfig = rollupConfig.replace(
|
||||
'compilerOptions:',
|
||||
'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:'
|
||||
);
|
||||
|
||||
// Add TypeScript
|
||||
rollupConfig = rollupConfig.replace(
|
||||
'commonjs(),',
|
||||
'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),'
|
||||
);
|
||||
fs.writeFileSync(rollupConfigPath, rollupConfig)
|
||||
|
||||
// Add TSConfig
|
||||
const tsconfig = `{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
|
||||
}`
|
||||
const tsconfigPath = path.join(projectRoot, "tsconfig.json")
|
||||
fs.writeFileSync(tsconfigPath, tsconfig)
|
||||
|
||||
// Add global.d.ts
|
||||
const dtsPath = path.join(projectRoot, "src", "global.d.ts")
|
||||
fs.writeFileSync(dtsPath, `/// <reference types="svelte" />`)
|
||||
|
||||
// Delete this script, but not during testing
|
||||
if (!argv[2]) {
|
||||
// Remove the script
|
||||
fs.unlinkSync(path.join(__filename))
|
||||
|
||||
// Check for Mac's DS_store file, and if it's the only one left remove it
|
||||
const remainingFiles = fs.readdirSync(path.join(__dirname))
|
||||
if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {
|
||||
fs.unlinkSync(path.join(__dirname, '.DS_store'))
|
||||
}
|
||||
|
||||
// Check if the scripts folder is empty
|
||||
if (fs.readdirSync(path.join(__dirname)).length === 0) {
|
||||
// Remove the scripts folder
|
||||
fs.rmdirSync(path.join(__dirname))
|
||||
}
|
||||
}
|
||||
|
||||
// Adds the extension recommendation
|
||||
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })
|
||||
fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
`)
|
||||
|
||||
console.log("Converted to TypeScript.")
|
||||
|
||||
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
|
||||
console.log("\nYou will need to re-run your dependency manager to get started.")
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import socketStore from "./socketStore.js";
|
||||
import Navbar from "./components/Navbar.svelte";
|
||||
import DeviceCard from "./components/DeviceCard.svelte";
|
||||
import Toast from "./components/Toast.svelte";
|
||||
|
||||
let visitors = 0;
|
||||
let devices = [];
|
||||
let settings = {};
|
||||
let toast = {
|
||||
title: "",
|
||||
message: "",
|
||||
color: "",
|
||||
show: false,
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
socketStore.subscribeStatus((status) => {
|
||||
if (status == "open") {
|
||||
showToast("Websocket", "Connected", "success");
|
||||
} else if (status == "close") {
|
||||
showToast(
|
||||
"Websocket",
|
||||
"Connection closed. Trying to reconnect ...",
|
||||
"danger"
|
||||
);
|
||||
} else if (status == "error") {
|
||||
showToast(
|
||||
"Websocket",
|
||||
"Error when connecting to websocket",
|
||||
"danger"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
socketStore.subscribeMsg((currentMessage) => {
|
||||
if (currentMessage.type == "init") {
|
||||
// create devices
|
||||
devices = [...currentMessage.message.devices];
|
||||
devices = devices;
|
||||
devices.sort(compare);
|
||||
settings = currentMessage.message.settings;
|
||||
} else if (currentMessage.type == "status") {
|
||||
// set device array and sort
|
||||
const index = devices.findIndex(
|
||||
(x) => x.id == currentMessage.message.id
|
||||
);
|
||||
if (devices.length === 0 || index === -1) {
|
||||
devices.push(currentMessage.message);
|
||||
devices = devices;
|
||||
} else {
|
||||
devices[index] = currentMessage.message;
|
||||
}
|
||||
devices.sort(compare);
|
||||
// set device status
|
||||
if (currentMessage.message.up == true) {
|
||||
setUp(currentMessage.message);
|
||||
} else {
|
||||
setDown(currentMessage.message);
|
||||
}
|
||||
} else if (currentMessage.type == "pending") {
|
||||
// set device pending
|
||||
setPending(currentMessage.message);
|
||||
} else if (currentMessage.type == "visitor") {
|
||||
// update visitor count
|
||||
visitors = currentMessage.message;
|
||||
} else if (currentMessage.type == "delete") {
|
||||
// delete device
|
||||
const devCol = document.querySelector(
|
||||
`#device-col-${currentMessage.message}`
|
||||
);
|
||||
devCol.remove();
|
||||
} else if (currentMessage.type == "scan_network") {
|
||||
// set scanned network devices
|
||||
if (!currentMessage.message) {
|
||||
return;
|
||||
}
|
||||
settings["scan_network"] = currentMessage.message;
|
||||
const btnScan = document.querySelector("#btnScan");
|
||||
const btnScanSpinner =
|
||||
document.querySelector("#btnScanSpinner");
|
||||
const btnScanText = document.querySelector("#btnScanText");
|
||||
btnScan.disabled = false;
|
||||
btnScanSpinner.classList.add("d-none");
|
||||
btnScanText.innerText = "Scan";
|
||||
} else if (currentMessage.type == "backup") {
|
||||
// download backup file
|
||||
const now = new Date();
|
||||
const fileName = `upsnap_backup_${now.toISOString()}.json`;
|
||||
const a = document.createElement("a");
|
||||
const file = new Blob(
|
||||
[JSON.stringify(currentMessage.message)],
|
||||
{ type: "text/plain" }
|
||||
);
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
} else if (currentMessage.type == "operationStatus") {
|
||||
if (currentMessage.message == "Success") {
|
||||
showToast(
|
||||
currentMessage.message,
|
||||
"Changes were saved",
|
||||
"success"
|
||||
);
|
||||
} else if (currentMessage.message == "Error") {
|
||||
showToast(
|
||||
currentMessage.message,
|
||||
"Error while saving the device. Please check the logs.",
|
||||
"danger"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setUp(device) {
|
||||
const dot = document.querySelector(`#dot-${device.id}`);
|
||||
const spinner = document.querySelector(`#spinner-${device.id}`);
|
||||
if (dot) {
|
||||
if (dot.classList.contains("danger")) {
|
||||
showToast(device.name, "Device is up!", "success");
|
||||
}
|
||||
dot.style.animation = "none";
|
||||
dot.offsetWidth;
|
||||
if (!spinner.classList.contains("d-none")) {
|
||||
spinner.classList.add("d-none");
|
||||
dot.classList.remove("d-none", "danger");
|
||||
dot.classList.add("success");
|
||||
} else {
|
||||
dot.style.animation = "on-pulse 1s normal";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDown(device) {
|
||||
const dot = document.querySelector(`#dot-${device.id}`);
|
||||
const spinner = document.querySelector(`#spinner-${device.id}`);
|
||||
if (dot) {
|
||||
if (dot.classList.contains("success")) {
|
||||
showToast(device.name, "Device is down!", "danger");
|
||||
}
|
||||
dot.style.animation = "none";
|
||||
dot.offsetWidth;
|
||||
if (!spinner.classList.contains("d-none")) {
|
||||
spinner.classList.add("d-none");
|
||||
dot.classList.remove("d-none", "success");
|
||||
dot.classList.add("danger");
|
||||
} else {
|
||||
dot.style.animation = "off-pulse 1s normal";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPending(id) {
|
||||
const dot = document.querySelector(`#dot-${id}`);
|
||||
const spinner = document.querySelector(`#spinner-${id}`);
|
||||
dot.classList.add("d-none");
|
||||
spinner.classList.remove("d-none");
|
||||
}
|
||||
|
||||
function showToast(title, message, color) {
|
||||
if (settings.notifications === false) {
|
||||
return;
|
||||
}
|
||||
toast.title = title;
|
||||
toast.message = message;
|
||||
toast.color = color;
|
||||
toast.show = true;
|
||||
setTimeout(() => {
|
||||
toast.show = false;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function compare(a, b) {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar {settings} {visitors} />
|
||||
<div class="container mb-3">
|
||||
<div class="row">
|
||||
{#each devices as device}
|
||||
<DeviceCard {device} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Toast {toast} />
|
||||
</main>
|
||||
|
||||
<style lang="scss" global>
|
||||
@import "./variables.scss";
|
||||
@import "../node_modules/bootstrap/scss/bootstrap";
|
||||
@import "../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
||||
@import "../node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
|
||||
@import "../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
||||
@import "theme.scss";
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-modal);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid var(--color-text);
|
||||
|
||||
.btn-close {
|
||||
background: var(--svg-close);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid var(--color-text);
|
||||
}
|
||||
|
||||
.btn,
|
||||
button {
|
||||
&.btn-light {
|
||||
color: var(--color-text);
|
||||
background-color: var(--bg-lighter);
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
&.btn-outline-success {
|
||||
border-color: $success;
|
||||
&:hover {
|
||||
background-color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline-danger {
|
||||
border-color: $danger;
|
||||
&:hover {
|
||||
background-color: $danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
&:focus {
|
||||
border-color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 1rem;
|
||||
border-left-width: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&.callout-info {
|
||||
background-color: var(--info-dark-transparent);
|
||||
border: 1px solid var(--info-dark-transparent);
|
||||
border-left: 5px solid var(--info);
|
||||
}
|
||||
|
||||
&.callout-danger {
|
||||
background-color: var(--danger-dark-transparent);
|
||||
border: 1px solid var(--danger-dark-transparent);
|
||||
border-left: 5px solid var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes on-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 $success;
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes off-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 $danger;
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script>
|
||||
const preferesDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
preferesDark.addEventListener("change", (e) => {
|
||||
setTheme(e.matches ? "dark" : "light");
|
||||
});
|
||||
|
||||
if (localStorage.getItem("data-theme") === null) {
|
||||
setTheme(preferesDark.matches ? "dark" : "light");
|
||||
} else {
|
||||
setTheme(localStorage.getItem("data-theme"));
|
||||
}
|
||||
|
||||
function setTheme(color) {
|
||||
if (color == "system") {
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
preferesDark ? "dark" : "light"
|
||||
);
|
||||
localStorage.setItem("data-theme", preferesDark ? "dark" : "light");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", color);
|
||||
localStorage.setItem("data-theme", color);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-light dropdown-toggle px-3 me-2 py-2"
|
||||
type="button"
|
||||
id="darkModeButton"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fa-solid fa-palette me-2" />Theme
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="darkModeButton">
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={() => setTheme("dark")}>
|
||||
<i class="fa-solid fa-moon me-2" />Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={() => setTheme("light")}>
|
||||
<i class="fa-solid fa-sun me-2" />Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={() => setTheme("system")}>
|
||||
<i class="fa-solid fa-desktop me-2" />System
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,671 +0,0 @@
|
||||
<script>
|
||||
import socketStore from "../socketStore.js";
|
||||
export let device;
|
||||
|
||||
let modalDevice = JSON.parse(JSON.stringify(device));
|
||||
let customPort = {};
|
||||
|
||||
function wake(id) {
|
||||
socketStore.sendMessage({
|
||||
type: "wake",
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
|
||||
function shutdown(id) {
|
||||
socketStore.sendMessage({
|
||||
type: "shutdown",
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
|
||||
function deleteDevice() {
|
||||
socketStore.sendMessage({
|
||||
type: "delete_device",
|
||||
id: modalDevice.id,
|
||||
});
|
||||
}
|
||||
|
||||
function updateDevice() {
|
||||
device = modalDevice;
|
||||
socketStore.sendMessage({
|
||||
type: "update_device",
|
||||
data: modalDevice,
|
||||
});
|
||||
}
|
||||
|
||||
function updatePort() {
|
||||
if (!customPort.number) {
|
||||
return;
|
||||
}
|
||||
const index = modalDevice.ports.findIndex(
|
||||
(x) => x.number == customPort.number
|
||||
);
|
||||
if (customPort.name) {
|
||||
// add port
|
||||
if (index === -1) {
|
||||
customPort.checked = true;
|
||||
modalDevice.ports.push(JSON.parse(JSON.stringify(customPort)));
|
||||
} else {
|
||||
customPort.checked = modalDevice.ports[index].checked;
|
||||
modalDevice.ports[index] = JSON.parse(
|
||||
JSON.stringify(customPort)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// delete port
|
||||
if (index >= 0) {
|
||||
modalDevice.ports.splice(index, 1);
|
||||
}
|
||||
}
|
||||
modalDevice = modalDevice;
|
||||
// send to backend
|
||||
socketStore.sendMessage({
|
||||
type: "update_port",
|
||||
data: customPort,
|
||||
});
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modalDevice = Object.assign({}, device);
|
||||
}
|
||||
|
||||
function validatePort() {
|
||||
if (typeof customPort.number != "number") {
|
||||
customPort.number = 1;
|
||||
} else if (customPort.number > 65535) {
|
||||
customPort.number = 65535;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="device-col-{device.id}"
|
||||
class="col-xs-12 col-sm-6 col-md-4 col-lg-3 g-4"
|
||||
>
|
||||
<div class="card border-0 p-3 pt-2">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-auto me-auto">
|
||||
<div
|
||||
id="spinner-{device.id}"
|
||||
class="spinner-border warning d-none"
|
||||
role="status"
|
||||
/>
|
||||
{#if device.up === true}
|
||||
{#if device.shutdown.command}
|
||||
<div
|
||||
class="hover"
|
||||
on:click={() => shutdown(device.id)}
|
||||
on:keydown={() => shutdown(device.id)}
|
||||
data-bs-toggle="tooltip"
|
||||
title="Shutdown command: {device.shutdown
|
||||
.command}"
|
||||
role="button"
|
||||
>
|
||||
<i
|
||||
id="dot-{device.id}"
|
||||
class="fa-solid fa-power-off fa-2x success"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<i
|
||||
id="dot-{device.id}"
|
||||
class="fa-solid fa-power-off fa-2x success"
|
||||
/>
|
||||
{/if}
|
||||
{:else if device.up === false}
|
||||
<div
|
||||
class="hover"
|
||||
on:click={() => wake(device.id)}
|
||||
on:keydown={() => wake(device.id)}
|
||||
role="button"
|
||||
>
|
||||
<i
|
||||
id="dot-{device.id}"
|
||||
class="fa-solid fa-power-off fa-2x danger"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hover" role="button">
|
||||
<i
|
||||
id="dot-{device.id}"
|
||||
class="fa-solid fa-power-off fa-2x text-muted"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if device.wake.enabled}
|
||||
<div
|
||||
class="col-auto px-2"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Wake cron: {device.wake.cron}"
|
||||
>
|
||||
<i class="fa-solid fa-circle-play fa-2x text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if device.shutdown.enabled}
|
||||
<div
|
||||
class="col-auto px-2"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Shutdown cron: {device.wake.cron}"
|
||||
>
|
||||
<i class="fa-solid fa-circle-stop fa-2x text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="col-auto hover"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#device-modal-{device.id}"
|
||||
role="button"
|
||||
on:click={() => openModal()}
|
||||
on:keydown={() => openModal()}
|
||||
>
|
||||
<i class="fa-solid fa-ellipsis-vertical fa-2x" />
|
||||
</div>
|
||||
</div>
|
||||
{#if device.link}
|
||||
<h5 class="card-title fw-bold my-2">
|
||||
<a class="inherit-color" href={device.link}>{device.name}</a
|
||||
>
|
||||
</h5>
|
||||
{:else}
|
||||
<h5 class="card-title fw-bold my-2">{device.name}</h5>
|
||||
{/if}
|
||||
<h6 class="card-subtitle mb-2 text-muted">{device.ip}</h6>
|
||||
<ul class="list-group">
|
||||
{#each device.ports as port}
|
||||
{#if port.checked === true}
|
||||
<li class="list-group-item">
|
||||
<i
|
||||
class="fa-solid fa-circle align-middle {port.open
|
||||
? 'success'
|
||||
: 'danger'}"
|
||||
/>
|
||||
{port.name}
|
||||
<span class="text-muted">({port.number})</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="device-modal-{device.id}"
|
||||
tabindex="-1"
|
||||
aria-labelledby="device-modal-{device.id}-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5
|
||||
class="modal-title fw-bold"
|
||||
id="device-modal-{modalDevice.id}-label"
|
||||
>
|
||||
{modalDevice.name}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form
|
||||
id="form-{modalDevice.id}"
|
||||
on:submit|preventDefault={updateDevice}
|
||||
>
|
||||
<!-- general -->
|
||||
<h5 class="fw-bold">General</h5>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm">
|
||||
<label
|
||||
for="inputName{modalDevice.id}"
|
||||
class="form-label">Device name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputName{modalDevice.id}"
|
||||
bind:value={modalDevice.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<label
|
||||
for="inputMac{modalDevice.id}"
|
||||
class="form-label">Mac address</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputMac{modalDevice.mac}"
|
||||
bind:value={modalDevice.mac}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm">
|
||||
<label
|
||||
for="inputIp{modalDevice.id}"
|
||||
class="form-label">IP address</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputIp{modalDevice.id}"
|
||||
bind:value={modalDevice.ip}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<label
|
||||
for="inputNetmask{modalDevice.id}"
|
||||
class="form-label">Netmask</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNetmask{modalDevice.id}"
|
||||
bind:value={modalDevice.netmask}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="inputLinkAddDevice"
|
||||
class="form-label">Web link</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputILinkAddDevice"
|
||||
placeholder="http://...."
|
||||
bind:value={modalDevice.link}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ports -->
|
||||
<h5 class="fw-bold mt-4">Ports</h5>
|
||||
<p class="mb-2">
|
||||
Select ports to check if they are open.
|
||||
</p>
|
||||
{#if modalDevice.ports.length === 0}
|
||||
<p class="mb-0">
|
||||
No ports available. Add ports below.
|
||||
</p>
|
||||
{/if}
|
||||
{#each modalDevice.ports as port}
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
id="{device.id}-port-{port.number}"
|
||||
bind:checked={port["checked"]}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="{device.id}-port-{port.number}"
|
||||
>{port.name}
|
||||
<span class="text-muted"
|
||||
>({port.number})</span
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<label
|
||||
class="form-label mt-2"
|
||||
for="{device.id}-custom-port">Custom port</label
|
||||
>
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
id="{device.id}-custom-port"
|
||||
class="form-control rounded-0 rounded-start"
|
||||
placeholder="Name"
|
||||
aria-label="Name"
|
||||
aria-describedby="button-addon2"
|
||||
bind:value={customPort.name}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="form-control rounded-0"
|
||||
placeholder="Port"
|
||||
aria-label="Port"
|
||||
aria-describedby="button-addon2"
|
||||
bind:value={customPort.number}
|
||||
on:input={validatePort}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
id="button-addon2"
|
||||
on:click={updatePort}>Update Port</button
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#info-ports"
|
||||
aria-expanded="false"
|
||||
aria-controls="info-ports"
|
||||
>
|
||||
<i class="fa-solid fa-angle-down me-2" />How to use
|
||||
</button>
|
||||
<div class="collapse mt-3" id="info-ports">
|
||||
<div class="callout callout-info">
|
||||
<p class="mb-0">
|
||||
Ports must be between 1 and 65535. Enter the
|
||||
same port with a differen name to change it.
|
||||
Leave name empty to delete port.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- scheduled wake -->
|
||||
<h5 class="fw-bold mt-4">Scheduled wake</h5>
|
||||
<p class="mb-2">Wake your device at a given time.</p>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="wake-disable"
|
||||
id="wake-radio-disabled-{modalDevice.id}"
|
||||
bind:group={modalDevice["wake"]["enabled"]}
|
||||
value={false}
|
||||
checked={!modalDevice["wake"]["enabled"]}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="wake-radio-disabled-{modalDevice.id}"
|
||||
>
|
||||
Disabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="wake-cron"
|
||||
id="wake-radio-cron-{modalDevice.id}"
|
||||
bind:group={modalDevice["wake"]["enabled"]}
|
||||
value={true}
|
||||
checked={modalDevice["wake"]["enabled"]}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="wake-radio-cron-{modalDevice.id}"
|
||||
>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="input-group my-1"
|
||||
hidden={!modalDevice["wake"]["enabled"]}
|
||||
>
|
||||
<span
|
||||
class="input-group-text rounded-0 rounded-start"
|
||||
id="wake-cron-{modalDevice.id}">Cron</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control rounded-0 rounded-end"
|
||||
placeholder="* /4 * * *"
|
||||
aria-label="Crontab"
|
||||
aria-describedby="wake-cron-{modalDevice.id}"
|
||||
bind:value={modalDevice["wake"]["cron"]}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#info-wake"
|
||||
aria-expanded="false"
|
||||
aria-controls="info-wake"
|
||||
>
|
||||
<i class="fa-solid fa-angle-down me-2" />How to use
|
||||
</button>
|
||||
<div class="collapse mt-3" id="info-wake">
|
||||
<div class="callout callout-info">
|
||||
<p class="mb-2">
|
||||
Cron is a syntax describing a time pattern
|
||||
when to execute jobs. The above field uses
|
||||
common cron syntax. Examples:
|
||||
</p>
|
||||
<pre
|
||||
class="mb-2">Minute Hour DayOfMonth Month DayOfWeek
|
||||
* /4 * * * (Wake every 4 hours)
|
||||
0 9 * * 1-5 (Wake from Mo-Fr at 9 a.m.)
|
||||
</pre>
|
||||
<p class="mb-0">
|
||||
Read more about <a
|
||||
href="https://linux.die.net/man/5/crontab"
|
||||
target="_blank"
|
||||
rel="noreferrer">valid syntax here</a
|
||||
>
|
||||
or
|
||||
<a
|
||||
href="https://crontab.guru/"
|
||||
target="_blank"
|
||||
rel="noreferrer">use a generator</a
|
||||
>. Expressions starting with "@..." are not
|
||||
supported.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- scheduled shutdown -->
|
||||
<h5 class="fw-bold mt-4">Shutdown</h5>
|
||||
<p class="mb-2">
|
||||
Set the shutdown command here. This shell command
|
||||
will be executed when clicking the power button on
|
||||
the device card. You can use cron below, which will
|
||||
then execute the command at the given time.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<span
|
||||
class="input-group-text rounded-0 rounded-start"
|
||||
id="shutdown-command-{modalDevice.id}"
|
||||
>Command</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control rounded-0 rounded-end"
|
||||
placeholder="sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo shutdown'"
|
||||
aria-label="Ccommand"
|
||||
aria-describedby="shutdown-command-{modalDevice.id}"
|
||||
bind:value={modalDevice["shutdown"]["command"]}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#info-shutdown"
|
||||
aria-expanded="false"
|
||||
aria-controls="info-shutdown"
|
||||
>
|
||||
<i class="fa-solid fa-angle-down me-2" />How to use
|
||||
</button>
|
||||
<div class="collapse mt-3" id="info-shutdown">
|
||||
<div class="callout callout-info">
|
||||
<p class="mb-2">
|
||||
This field takes a shell command to trigger
|
||||
the shutdown. You can use <code
|
||||
>sshpass</code
|
||||
>
|
||||
for Linux or <code>net rpc</code> for Windows
|
||||
hosts.
|
||||
</p>
|
||||
<div class="callout callout-danger mb-2">
|
||||
Note: This command is safed as cleartext.
|
||||
Meaning, passwords are clearly visible in
|
||||
the database.
|
||||
</div>
|
||||
<p class="mb-2">Examples:</p>
|
||||
<pre class="mb-2"># wake linux hosts
|
||||
sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo shutdown'
|
||||
|
||||
# wake windows hosts
|
||||
net rpc shutdown --ipaddress 192.168.1.1 --user user%password
|
||||
</pre>
|
||||
<p class="mb-0">
|
||||
Read more about <a
|
||||
href="https://linux.die.net/man/1/sshpass"
|
||||
target="_blank"
|
||||
rel="noreferrer">sshpass</a
|
||||
>
|
||||
or
|
||||
<a
|
||||
href="https://linux.die.net/man/8/net"
|
||||
target="_blank"
|
||||
rel="noreferrer">net rpc</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="form-label mt-2"
|
||||
for="{device.id}-shutdown-cron">Use cron</label
|
||||
>
|
||||
</div>
|
||||
<div class="form-check" id="{device.id}-shutdown-cron">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="shutdown-disable"
|
||||
id="shutdown-radio-disabled-{modalDevice.id}"
|
||||
bind:group={modalDevice["shutdown"]["enabled"]}
|
||||
value={false}
|
||||
checked={!modalDevice["shutdown"]["enabled"]}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="shutdown-radio-disabled-{modalDevice.id}"
|
||||
>
|
||||
Disabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="shutdown-enable"
|
||||
id="shutdown-radio-enabled-{modalDevice.id}"
|
||||
bind:group={modalDevice["shutdown"]["enabled"]}
|
||||
value={true}
|
||||
checked={modalDevice["shutdown"]["enabled"]}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="shutdown-radio-enabled-{modalDevice.id}"
|
||||
>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="input-group my-1"
|
||||
hidden={!modalDevice["shutdown"]["enabled"]}
|
||||
>
|
||||
<span
|
||||
class="input-group-text rounded-0 rounded-start"
|
||||
id="shutdown-cron-{modalDevice.id}">Cron</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control rounded-0 rounded-end"
|
||||
placeholder="* /4 * * *"
|
||||
aria-label="Crontab"
|
||||
aria-describedby="shutdown-cron-{modalDevice.id}"
|
||||
bind:value={modalDevice["shutdown"]["cron"]}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
data-bs-dismiss="modal"
|
||||
on:click={deleteDevice}>Delete</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
form="form-{modalDevice.id}"
|
||||
class="btn btn-outline-success">Save changes</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-line;
|
||||
background-color: var(--color-bg);
|
||||
padding: 1em;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 2em;
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.hover {
|
||||
&:hover {
|
||||
text-shadow: 0px 0px 20px rgb(155, 155, 155);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-2x {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.fa-power-off {
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.fa-circle {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.inherit-color {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -1,505 +0,0 @@
|
||||
<script>
|
||||
import socketStore from "../socketStore.js";
|
||||
import DarkToggle from "./DarkToggle.svelte";
|
||||
export let visitors;
|
||||
export let settings;
|
||||
const pagetitle = PAGE_TITLE ? PAGE_TITLE : "UpSnap";
|
||||
|
||||
let addDevice = {
|
||||
wake: {
|
||||
enabled: false,
|
||||
cron: "",
|
||||
},
|
||||
shutdown: {
|
||||
enabled: false,
|
||||
cron: "",
|
||||
command: "",
|
||||
},
|
||||
};
|
||||
|
||||
function updateDevice(data) {
|
||||
if (Object.keys(data).length < 4) {
|
||||
return;
|
||||
}
|
||||
socketStore.sendMessage({
|
||||
type: "update_device",
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
function updateSettings() {
|
||||
socketStore.sendMessage({
|
||||
type: "update_settings",
|
||||
data: settings,
|
||||
});
|
||||
hideModal("settings");
|
||||
}
|
||||
|
||||
function scanNetwork() {
|
||||
socketStore.sendMessage({
|
||||
type: "scan_network",
|
||||
});
|
||||
const btnScan = document.querySelector("#btnScan");
|
||||
const btnScanSpinner = document.querySelector("#btnScanSpinner");
|
||||
const btnScanText = document.querySelector("#btnScanText");
|
||||
btnScan.disabled = true;
|
||||
btnScanSpinner.classList.remove("d-none");
|
||||
btnScanText.innerText = "Scanning...";
|
||||
}
|
||||
|
||||
function addScan(i) {
|
||||
document.querySelector(`#btnAdd${i}`).disabled = true;
|
||||
const dev = settings.scan_network[i];
|
||||
updateDevice(dev);
|
||||
}
|
||||
|
||||
const restoreFromFile = (e) => {
|
||||
let file = e.target.files[0];
|
||||
let reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
reader.onload = (e) => {
|
||||
let data = JSON.parse(e.target.result);
|
||||
if (Array.isArray(data)) {
|
||||
// v2 file restore
|
||||
data.forEach((device) => {
|
||||
updateDevice(device);
|
||||
});
|
||||
} else {
|
||||
// v1 file restore
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
value["mac"] = key;
|
||||
value["link"] = "";
|
||||
updateDevice(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
hideModal("settings");
|
||||
};
|
||||
|
||||
function backupToFile() {
|
||||
socketStore.sendMessage({
|
||||
type: "backup",
|
||||
});
|
||||
}
|
||||
|
||||
function hideModal(id) {
|
||||
const modalEl = document.querySelector(`#${id}`);
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
modal.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pagetitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<nav class="navbar navbar-expand-sm">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-brand" href="/">
|
||||
<img
|
||||
src="favicon.png"
|
||||
alt="Logo"
|
||||
width="24"
|
||||
height="24"
|
||||
class="me-2"
|
||||
/>
|
||||
{pagetitle}
|
||||
</div>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon" />
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
|
||||
<span class="ms-auto d-flex">
|
||||
<DarkToggle />
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-light dropdown-toggle px-3 me-2 py-2"
|
||||
type="button"
|
||||
id="dropdownMenuButton1"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fa-solid fa-wrench me-2" />More
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdownMenuButton1"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#addDevice"
|
||||
>
|
||||
<i class="fa-solid fa-plus me-2" />Add device
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#settings"
|
||||
>
|
||||
<i class="fa-solid fa-sliders me-2" />Settings
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn btn-light px-3 me-2 py-2 pe-none">
|
||||
<i
|
||||
class="me-2 fa-solid {visitors === 1
|
||||
? 'fa-user'
|
||||
: 'fa-user-group'}"
|
||||
/>{visitors}
|
||||
{visitors === 1 ? "Visitor" : "Visitors"}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="addDevice"
|
||||
tabindex="-1"
|
||||
aria-labelledby="addDeviceLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold" id="addDeviceLabel">
|
||||
Add device
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form
|
||||
id="addForm"
|
||||
on:submit|preventDefault={() => updateDevice(addDevice)}
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="inputNameAddDevice"
|
||||
class="form-label">Device name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNameAddDevice"
|
||||
placeholder="Max PC"
|
||||
bind:value={addDevice.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="inputMacAddDevice"
|
||||
class="form-label">Mac address</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputMacAddDevice"
|
||||
placeholder="aa:aa:aa:aa:aa:aa"
|
||||
bind:value={addDevice.mac}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label for="inputIpAddDevice" class="form-label"
|
||||
>IP address</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputIpAddDevice"
|
||||
placeholder="192.168.1.1"
|
||||
bind:value={addDevice.ip}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="inputNetmaskAddDevice"
|
||||
class="form-label">Netmask</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNetmaskAddDevice"
|
||||
placeholder="255.255.255.0"
|
||||
bind:value={addDevice.netmask}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="mb-3">
|
||||
<label for="inputLinkAddDevice" class="form-label"
|
||||
>Web link</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputILinkAddDevice"
|
||||
placeholder="http://...."
|
||||
bind:value={addDevice.link}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-auto ms-auto">
|
||||
<button
|
||||
type="submit"
|
||||
form="addForm"
|
||||
class="btn btn-outline-success"
|
||||
>Add device</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="fw-bold">Network discovery</h5>
|
||||
{#if !settings.discovery}
|
||||
<div class="callout callout-danger mb-2">
|
||||
<p class="m-0">
|
||||
To enable this option, please enter your network
|
||||
address in the settings.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
id="btnScan"
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
on:click={scanNetwork}
|
||||
disabled={!settings.discovery}
|
||||
>
|
||||
<span
|
||||
id="btnScanSpinner"
|
||||
class="spinner-grow spinner-grow-sm d-none"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span id="btnScanText">Scan</span>
|
||||
</button>
|
||||
{#if settings.scan_network?.length}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>IP</td>
|
||||
<td>Netmask</td>
|
||||
<td>Mac</td>
|
||||
<td>Add</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each settings.scan_network as device, i}
|
||||
<tr>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ip}</td>
|
||||
<td>{device.netmask}</td>
|
||||
<td>{device.mac}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
id="btnAdd{i}"
|
||||
class="btn btn-outline-secondary py-0"
|
||||
on:click={() => addScan(i)}
|
||||
>
|
||||
<i
|
||||
class="fa-solid fa-plus fa-sm"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="settings"
|
||||
tabindex="-1"
|
||||
aria-labelledby="settingsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold" id="settingsLabel">Settings</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form
|
||||
id="settingsForm"
|
||||
on:submit|preventDefault={updateSettings}
|
||||
>
|
||||
<h5 class="fw-bold">General</h5>
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="inputNetworkDiscovery"
|
||||
class="form-label"
|
||||
>Network discovery address</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNetworkDiscovery"
|
||||
placeholder="192.168.1.0/24"
|
||||
bind:value={settings.discovery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="inputIntervalSettings"
|
||||
class="form-label">Interval (seconds)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
id="inputIntervalSettings"
|
||||
min="5"
|
||||
bind:value={settings.interval}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-check">
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="flexCheckDefault"
|
||||
>
|
||||
Enable notifications
|
||||
</label>
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value=""
|
||||
id="flexCheckDefault"
|
||||
bind:checked={settings.notifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row mb-3">
|
||||
<div class="col-auto ms-auto">
|
||||
<button
|
||||
type="submit"
|
||||
form="settingsForm"
|
||||
class="btn btn-outline-success">Save</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="fw-bold">Backup/Restore</h5>
|
||||
<div class="callout callout-info mb-2">
|
||||
<p class="m-0">
|
||||
Backup file structure has changed in v2. You can still
|
||||
restore both versions with this file upload.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="inputRestore" class="form-label"
|
||||
>Restore from .json</label
|
||||
>
|
||||
<input
|
||||
id="inputRestore"
|
||||
type="file"
|
||||
class="form-control"
|
||||
accept=".json"
|
||||
on:change={(e) => restoreFromFile(e)}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
on:click={backupToFile}
|
||||
>
|
||||
<i class="fa-solid fa-download me-2" />
|
||||
Export .json
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" global>
|
||||
.table {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: var(--bg-lighter);
|
||||
|
||||
li:first-child > button {
|
||||
border-radius: 0.5rem 0.5rem 0rem 0rem;
|
||||
}
|
||||
|
||||
li:last-child > button {
|
||||
border-radius: 0rem 0rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
li:only-child > button {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--color-text);
|
||||
background-color: var(--bg-lighter);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-bg);
|
||||
background-color: var(--color-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script>
|
||||
export let toast;
|
||||
</script>
|
||||
|
||||
<div class="position-fixed bottom-0 end-0 p-3 toast-container">
|
||||
<div
|
||||
class="toast fade {toast.show ? 'show' : 'hide'} {toast.color}-bg"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="toast-header">
|
||||
<strong id="toast-title" class="me-auto">{toast.title}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="toast"
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div class="toast-body fw-bold">
|
||||
{toast.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../variables.scss";
|
||||
|
||||
.toast-container {
|
||||
z-index: 11;
|
||||
border-radius: $toast-border-radius;
|
||||
|
||||
div > .toast-body {
|
||||
color: $dark-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
.success-bg {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.danger-bg {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
|
||||
const app = new App({
|
||||
target: document.body
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,57 +0,0 @@
|
||||
import {
|
||||
writable
|
||||
} from 'svelte/store';
|
||||
|
||||
const status = writable('');
|
||||
const message = writable('');
|
||||
let socket;
|
||||
|
||||
function initSocket() {
|
||||
if (BACKEND_IS_PROXIED) {
|
||||
const socketUrl = new URL('wol/', window.location.href);
|
||||
socketUrl.protocol = socketUrl.protocol.replace('http', 'ws');
|
||||
|
||||
socket = new WebSocket(socketUrl);
|
||||
}
|
||||
else {
|
||||
socket = new WebSocket(`ws://${location.hostname}:${BACKEND_PORT}/wol/`);
|
||||
}
|
||||
|
||||
// Connection opened
|
||||
socket.addEventListener('open', function () {
|
||||
status.set("open");
|
||||
});
|
||||
|
||||
// Connection closed
|
||||
socket.addEventListener('close', function () {
|
||||
status.set("close");
|
||||
setTimeout(function () {
|
||||
initSocket();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Connection error
|
||||
socket.addEventListener('error', function () {
|
||||
status.set("error");
|
||||
socket.close()
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
socket.addEventListener('message', function (event) {
|
||||
message.set(JSON.parse(event.data));
|
||||
});
|
||||
}
|
||||
|
||||
initSocket()
|
||||
|
||||
const sendMessage = (message) => {
|
||||
if (socket.readyState <= 1) {
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
subscribeMsg: message.subscribe,
|
||||
subscribeStatus: status.subscribe,
|
||||
sendMessage
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
:root {
|
||||
--success: #{$success};
|
||||
--warning: #{$warning};
|
||||
--danger: #{$danger};
|
||||
--danger-dark-transparent: #{$danger-dark-transparent};
|
||||
--info: #{$info};
|
||||
--info-dark-transparent: #{$info-dark-transparent};
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--color-bg: #{$light};
|
||||
--color-text: #{$dark};
|
||||
--bg-lighter: #{$light-darker};
|
||||
--bg-modal: #{$light};
|
||||
--svg-close: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--color-bg: #{$dark};
|
||||
--color-text: #{$light};
|
||||
--bg-lighter: #{$dark-lighter};
|
||||
--bg-modal: #{$dark-lighter};
|
||||
--svg-close: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
|
||||
}
|
||||
17
app/run.sh
17
app/run.sh
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# init django
|
||||
cd /app/backend/ || exit
|
||||
python3 manage.py makemigrations
|
||||
python3 manage.py migrate
|
||||
python3 manage.py collectstatic --noinput
|
||||
python3 manage.py shell < setup.py
|
||||
python3 -m celery -A backend worker &
|
||||
python3 -m celery -A backend beat &
|
||||
python3 -m gunicorn --bind 0.0.0.0:"${BACKEND_PORT}" --workers 4 backend.asgi:application -k uvicorn.workers.UvicornWorker &
|
||||
|
||||
# start frontend
|
||||
cd /app/frontend/ || exit
|
||||
npm install
|
||||
npm run build
|
||||
PORT=$FRONTEND_PORT npm start
|
||||
111
backend/controllers/device_controller.go
Normal file
111
backend/controllers/device_controller.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/seriousm4x/UpSnap/models"
|
||||
"github.com/seriousm4x/UpSnap/queries"
|
||||
)
|
||||
|
||||
func GetDevices(c *fiber.Ctx) error {
|
||||
var devices []models.Device
|
||||
|
||||
if err := queries.GetAllDevices(&devices); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"error": false,
|
||||
"devices": devices,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateDevice(c *fiber.Ctx) error {
|
||||
var device models.Device
|
||||
|
||||
if err := c.BodyParser(&device); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := queries.CreateDevice(&device); err != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"error": false,
|
||||
"msg": "created",
|
||||
"device": device,
|
||||
})
|
||||
}
|
||||
|
||||
func PatchDevice(c *fiber.Ctx) error {
|
||||
var params map[string]interface{}
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": "strconv: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := c.BodyParser(¶ms); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": "paramsparser: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := queries.PatchDevice(¶ms, id); err != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": "patching: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"error": false,
|
||||
"msg": "patched",
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteDevice(c *fiber.Ctx) error {
|
||||
var device models.Device
|
||||
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := queries.GetOneDevice(&device, id); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := queries.DeleteDevice(&device, id); err != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"error": true,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"error": false,
|
||||
"msg": "deleted",
|
||||
})
|
||||
}
|
||||
31
backend/database/database.go
Normal file
31
backend/database/database.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seriousm4x/UpSnap/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func init() {
|
||||
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect database")
|
||||
}
|
||||
|
||||
// Migrate the schema
|
||||
db.AutoMigrate(&models.Settings{}, &models.Port{}, &models.Device{})
|
||||
|
||||
DB = db
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
db, err := DB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("gorm.DB get database: %v", err)
|
||||
}
|
||||
return db.Close()
|
||||
}
|
||||
28
backend/go.mod
Normal file
28
backend/go.mod
Normal file
@@ -0,0 +1,28 @@
|
||||
module github.com/seriousm4x/UpSnap
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.40.1
|
||||
github.com/gofiber/websocket/v2 v2.1.2
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/fasthttp/websocket v1.5.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.12 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.41.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
|
||||
)
|
||||
58
backend/go.sum
Normal file
58
backend/go.sum
Normal file
@@ -0,0 +1,58 @@
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/fasthttp/websocket v1.5.0 h1:B4zbe3xXyvIdnqjOZrafVFklCUq5ZLo/TqCt5JA1wLE=
|
||||
github.com/fasthttp/websocket v1.5.0/go.mod h1:n0BlOQvJdPbTuBkZT0O5+jk/sp/1/VCzquR1BehI2F4=
|
||||
github.com/gofiber/fiber/v2 v2.40.1 h1:pc7n9VVpGIqNsvg9IPLQhyFEMJL8gCs1kneH5D1pIl4=
|
||||
github.com/gofiber/fiber/v2 v2.40.1/go.mod h1:Gko04sLksnHbzLSRBFWPFdzM9Ws9pRxvvIaohJK1dsk=
|
||||
github.com/gofiber/websocket/v2 v2.1.2 h1:EulKyLB/fJgui5+6c8irwEnYQ9FRsrLZfkrq9OfTDGc=
|
||||
github.com/gofiber/websocket/v2 v2.1.2/go.mod h1:S+sKWo0xeC7Wnz5h4/8f6D/NxsrLFIdWDYB3SyVO9pE=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899 h1:Orn7s+r1raRTBKLSc9DmbktTT04sL+vkzsbRD2Q8rOI=
|
||||
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4=
|
||||
github.com/valyala/fasthttp v1.41.0 h1:zeR0Z1my1wDHTRiamBCXVglQdbUwgb9uWG3k1HQz6jY=
|
||||
github.com/valyala/fasthttp v1.41.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.9 h1:NSHG021i+MCznokeXR3udGaNyFyBQJW8MbjrJMVCfGw=
|
||||
gorm.io/gorm v1.23.9/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
53
backend/main.go
Normal file
53
backend/main.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/monitor"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"github.com/seriousm4x/UpSnap/controllers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := fiber.New()
|
||||
app.Use(recover.New())
|
||||
app.Get("/metrics", monitor.New(monitor.Config{Title: "UpSnap Metrics Page"}))
|
||||
|
||||
devicesGroup := app.Group("/devices")
|
||||
devicesGroup.Get("/", controllers.GetDevices)
|
||||
devicesGroup.Post("/", controllers.CreateDevice)
|
||||
devicesGroup.Patch("/:id", controllers.PatchDevice)
|
||||
devicesGroup.Delete("/:id", controllers.DeleteDevice)
|
||||
|
||||
app.Use("/ws", func(c *fiber.Ctx) error {
|
||||
if websocket.IsWebSocketUpgrade(c) {
|
||||
c.Locals("allowed", true)
|
||||
return c.Next()
|
||||
}
|
||||
return fiber.ErrUpgradeRequired
|
||||
})
|
||||
|
||||
app.Get("/ws/:id", websocket.New(func(c *websocket.Conn) {
|
||||
var (
|
||||
mt int
|
||||
msg []byte
|
||||
err error
|
||||
)
|
||||
for {
|
||||
if mt, msg, err = c.ReadMessage(); err != nil {
|
||||
log.Println("read:", err)
|
||||
break
|
||||
}
|
||||
log.Printf("recv: %s", msg)
|
||||
|
||||
if err = c.WriteMessage(mt, msg); err != nil {
|
||||
log.Println("write:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}))
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
}
|
||||
14
backend/models/device_model.go
Normal file
14
backend/models/device_model.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Device struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"not null; size:100" json:"name" binding:"required"`
|
||||
IP string `gorm:"not null" json:"ip" binding:"required"`
|
||||
Mac string `gorm:"not null; size:17" json:"mac" binding:"required"`
|
||||
Netmask string `gorm:"not null; size:15; default:255.255.255.0" json:"netmask" binding:"required"`
|
||||
Link string `json:"link"`
|
||||
Ports []Port `gorm:"many2many:device_ports;" json:"ports"`
|
||||
ShutdownCmd string `json:"shutdown_cmd"`
|
||||
}
|
||||
9
backend/models/port_model.go
Normal file
9
backend/models/port_model.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Port struct {
|
||||
gorm.Model
|
||||
Number uint16 `gorm:"not null" json:"number" binding:"number"`
|
||||
Name string `gorm:"not null" json:"name" binding:"name"`
|
||||
}
|
||||
11
backend/models/settings_model.go
Normal file
11
backend/models/settings_model.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Settings struct {
|
||||
gorm.Model
|
||||
SortBy string `gorm:"not null; default:name" json:"sort_by" binding:"required"`
|
||||
ScanAddress string `gorm:"not null" json:"scan_address"`
|
||||
PingInterval uint `gorm:"not null; default:5" json:"ping_interval"`
|
||||
NotificationsEnabled bool `gorm:"not null; default:true" json:"notifications_enabled" binding:"required"`
|
||||
}
|
||||
51
backend/queries/device_queries.go
Normal file
51
backend/queries/device_queries.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"github.com/seriousm4x/UpSnap/database"
|
||||
"github.com/seriousm4x/UpSnap/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func GetAllDevices(d *[]models.Device) error {
|
||||
if result := database.DB.Model(d).Find(d); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateDevice(d *models.Device) error {
|
||||
if result := database.DB.Model(d).Create(&d); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetOneDevice(d *models.Device, id int) error {
|
||||
result := database.DB.Model(d).Where("id = ?", id).Find(d)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PatchDevice(changes *map[string]interface{}, id int) error {
|
||||
var device models.Device
|
||||
if err := GetOneDevice(&device, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if result := database.DB.Model(device).Where("id = ?", id).Updates(changes); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteDevice(d *models.Device, id int) error {
|
||||
var device models.Device
|
||||
if result := database.DB.Where("id = ?", id).Delete(&device); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
version: "3"
|
||||
services:
|
||||
app:
|
||||
container_name: upsnap_app
|
||||
image: seriousm4x/upsnap:latest
|
||||
upsnap_frontend:
|
||||
container_name: upsnap_frontend
|
||||
build:
|
||||
context: app/frontend/
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PAGE_TITLE=Custom Title # optional, set a custom page title (default: UpSnap)
|
||||
ports:
|
||||
- 3000:3000
|
||||
upsnap_backend:
|
||||
container_name: upsnap_backend
|
||||
#image: seriousm4x/upsnap:latest
|
||||
build:
|
||||
context: app/backend/
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FRONTEND_PORT=8000
|
||||
- BACKEND_PORT=8001
|
||||
- BACKEND_IS_PROXIED=false # set this to true, if you use a reverse proxy
|
||||
- DB_TYPE=sqlite # required
|
||||
@@ -20,7 +30,6 @@ services:
|
||||
#- 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:
|
||||
|
||||
13
frontend/.eslintignore
Normal file
13
frontend/.eslintignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
20
frontend/.eslintrc.cjs
Normal file
20
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
||||
10
frontend/.gitignore
vendored
Normal file
10
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
13
frontend/.prettierignore
Normal file
13
frontend/.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:alpine as build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN ls -lah && npm i && \
|
||||
npm run build && \
|
||||
npm prune --omit=dev
|
||||
|
||||
FROM node:alpine
|
||||
USER node:node
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=node:node /app/build ./build
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --chown=node:node package.json .
|
||||
CMD ["node","build"]
|
||||
38
frontend/README.md
Normal file
38
frontend/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
5210
frontend/package-lock.json
generated
Normal file
5210
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.0.0",
|
||||
"@sveltejs/kit": "^1.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.8.1",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^2.9.2",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.2.1",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"bootstrap": "5.2.3",
|
||||
"sass": "^1.57.0"
|
||||
}
|
||||
}
|
||||
1919
frontend/pnpm-lock.yaml
generated
Normal file
1919
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/src/app.d.ts
vendored
Normal file
9
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
58
frontend/src/components/DarkToggle.svelte
Normal file
58
frontend/src/components/DarkToggle.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
if (browser) {
|
||||
const preferesDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
preferesDark.addEventListener('change', (e) => {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
if (localStorage.getItem('data-theme') === null) {
|
||||
setTheme(preferesDark.matches ? 'dark' : 'light');
|
||||
} else {
|
||||
setTheme(localStorage.getItem('data-theme'));
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(color) {
|
||||
const preferesDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
if (color == 'system') {
|
||||
document.documentElement.setAttribute('data-theme', preferesDark ? 'dark' : 'light');
|
||||
localStorage.setItem('data-theme', preferesDark ? 'dark' : 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', color);
|
||||
localStorage.setItem('data-theme', color);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-light dropdown-toggle px-3 me-2 py-2"
|
||||
type="button"
|
||||
id="darkModeButton"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fa-solid fa-palette me-2" />Theme
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="darkModeButton">
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={() => setTheme('dark')}>
|
||||
<i class="fa-solid fa-moon me-2" />Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={() => setTheme('light')}>
|
||||
<i class="fa-solid fa-sun me-2" />Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" on:click={() => setTheme('system')}>
|
||||
<i class="fa-solid fa-desktop me-2" />System
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
509
frontend/src/components/DeviceCard.svelte
Normal file
509
frontend/src/components/DeviceCard.svelte
Normal file
@@ -0,0 +1,509 @@
|
||||
<script>
|
||||
import socketStore from '@stores/socket';
|
||||
export let device;
|
||||
|
||||
let modalDevice = JSON.parse(JSON.stringify(device));
|
||||
let customPort = {};
|
||||
|
||||
function wake(id) {
|
||||
socketStore.sendMessage({
|
||||
type: 'wake',
|
||||
id: id
|
||||
});
|
||||
}
|
||||
|
||||
function shutdown(id) {
|
||||
socketStore.sendMessage({
|
||||
type: 'shutdown',
|
||||
id: id
|
||||
});
|
||||
}
|
||||
|
||||
function deleteDevice() {
|
||||
socketStore.sendMessage({
|
||||
type: 'delete_device',
|
||||
id: modalDevice.id
|
||||
});
|
||||
}
|
||||
|
||||
function updateDevice() {
|
||||
device = modalDevice;
|
||||
socketStore.sendMessage({
|
||||
type: 'update_device',
|
||||
data: modalDevice
|
||||
});
|
||||
}
|
||||
|
||||
function updatePort() {
|
||||
if (!customPort.number) {
|
||||
return;
|
||||
}
|
||||
const index = modalDevice.ports.findIndex((x) => x.number == customPort.number);
|
||||
if (customPort.name) {
|
||||
// add port
|
||||
if (index === -1) {
|
||||
customPort.checked = true;
|
||||
modalDevice.ports.push(JSON.parse(JSON.stringify(customPort)));
|
||||
} else {
|
||||
customPort.checked = modalDevice.ports[index].checked;
|
||||
modalDevice.ports[index] = JSON.parse(JSON.stringify(customPort));
|
||||
}
|
||||
} else {
|
||||
// delete port
|
||||
if (index >= 0) {
|
||||
modalDevice.ports.splice(index, 1);
|
||||
}
|
||||
}
|
||||
modalDevice = modalDevice;
|
||||
// send to backend
|
||||
socketStore.sendMessage({
|
||||
type: 'update_port',
|
||||
data: customPort
|
||||
});
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modalDevice = Object.assign({}, device);
|
||||
}
|
||||
|
||||
function validatePort() {
|
||||
if (typeof customPort.number != 'number') {
|
||||
customPort.number = 1;
|
||||
} else if (customPort.number > 65535) {
|
||||
customPort.number = 65535;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="device-col-{device.id}" class="col-xs-12 col-sm-6 col-md-4 col-lg-3 g-4">
|
||||
<div class="card border-0 p-3 pt-2">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-auto me-auto">
|
||||
<div id="spinner-{device.id}" class="spinner-border warning d-none" role="status" />
|
||||
{#if device.up === true}
|
||||
{#if device.shutdown.command}
|
||||
<div
|
||||
class="hover"
|
||||
on:click={() => shutdown(device.id)}
|
||||
on:keydown={() => shutdown(device.id)}
|
||||
data-bs-toggle="tooltip"
|
||||
title="Shutdown command: {device.shutdown.command}"
|
||||
role="button"
|
||||
>
|
||||
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x success" />
|
||||
</div>
|
||||
{:else}
|
||||
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x success" />
|
||||
{/if}
|
||||
{:else if device.up === false}
|
||||
<div
|
||||
class="hover"
|
||||
on:click={() => wake(device.id)}
|
||||
on:keydown={() => wake(device.id)}
|
||||
role="button"
|
||||
>
|
||||
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x danger" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hover" role="button">
|
||||
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if device.wake.enabled}
|
||||
<div class="col-auto px-2" data-bs-toggle="tooltip" title="Wake cron: {device.wake.cron}">
|
||||
<i class="fa-solid fa-circle-play fa-2x text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if device.shutdown.enabled}
|
||||
<div
|
||||
class="col-auto px-2"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Shutdown cron: {device.wake.cron}"
|
||||
>
|
||||
<i class="fa-solid fa-circle-stop fa-2x text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="col-auto hover"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#device-modal-{device.id}"
|
||||
role="button"
|
||||
on:click={() => openModal()}
|
||||
on:keydown={() => openModal()}
|
||||
>
|
||||
<i class="fa-solid fa-ellipsis-vertical fa-2x" />
|
||||
</div>
|
||||
</div>
|
||||
{#if device.link}
|
||||
<h5 class="card-title fw-bold my-2">
|
||||
<a class="inherit-color" href={device.link}>{device.name}</a>
|
||||
</h5>
|
||||
{:else}
|
||||
<h5 class="card-title fw-bold my-2">{device.name}</h5>
|
||||
{/if}
|
||||
<h6 class="card-subtitle mb-2 text-muted">{device.ip}</h6>
|
||||
<ul class="list-group">
|
||||
{#each device.ports as port}
|
||||
{#if port.checked === true}
|
||||
<li class="list-group-item">
|
||||
<i class="fa-solid fa-circle align-middle {port.open ? 'success' : 'danger'}" />
|
||||
{port.name}
|
||||
<span class="text-muted">({port.number})</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="device-modal-{device.id}"
|
||||
tabindex="-1"
|
||||
aria-labelledby="device-modal-{device.id}-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold" id="device-modal-{modalDevice.id}-label">
|
||||
{modalDevice.name}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="form-{modalDevice.id}" on:submit|preventDefault={updateDevice}>
|
||||
<!-- general -->
|
||||
<h5 class="fw-bold">General</h5>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm">
|
||||
<label for="inputName{modalDevice.id}" class="form-label">Device name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputName{modalDevice.id}"
|
||||
bind:value={modalDevice.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<label for="inputMac{modalDevice.id}" class="form-label">Mac address</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputMac{modalDevice.mac}"
|
||||
bind:value={modalDevice.mac}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm">
|
||||
<label for="inputIp{modalDevice.id}" class="form-label">IP address</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputIp{modalDevice.id}"
|
||||
bind:value={modalDevice.ip}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<label for="inputNetmask{modalDevice.id}" class="form-label">Netmask</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNetmask{modalDevice.id}"
|
||||
bind:value={modalDevice.netmask}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="mb-3">
|
||||
<label for="inputLinkAddDevice" class="form-label">Web link</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputILinkAddDevice"
|
||||
placeholder="http://...."
|
||||
bind:value={modalDevice.link}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ports -->
|
||||
<h5 class="fw-bold mt-4">Ports</h5>
|
||||
<p class="mb-2">Select ports to check if they are open.</p>
|
||||
{#if modalDevice.ports.length === 0}
|
||||
<p class="mb-0">No ports available. Add ports below.</p>
|
||||
{/if}
|
||||
{#each modalDevice.ports as port}
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
id="{device.id}-port-{port.number}"
|
||||
bind:checked={port['checked']}
|
||||
/>
|
||||
<label class="form-check-label" for="{device.id}-port-{port.number}"
|
||||
>{port.name}
|
||||
<span class="text-muted">({port.number})</span></label
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<label class="form-label mt-2" for="{device.id}-custom-port">Custom port</label>
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
id="{device.id}-custom-port"
|
||||
class="form-control rounded-0 rounded-start"
|
||||
placeholder="Name"
|
||||
aria-label="Name"
|
||||
aria-describedby="button-addon2"
|
||||
bind:value={customPort.name}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="form-control rounded-0"
|
||||
placeholder="Port"
|
||||
aria-label="Port"
|
||||
aria-describedby="button-addon2"
|
||||
bind:value={customPort.number}
|
||||
on:input={validatePort}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
id="button-addon2"
|
||||
on:click={updatePort}>Update Port</button
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#info-ports"
|
||||
aria-expanded="false"
|
||||
aria-controls="info-ports"
|
||||
>
|
||||
<i class="fa-solid fa-angle-down me-2" />How to use
|
||||
</button>
|
||||
<div class="collapse mt-3" id="info-ports">
|
||||
<div class="callout callout-info">
|
||||
<p class="mb-0">
|
||||
Ports must be between 1 and 65535. Enter the same port with a differen name to
|
||||
change it. Leave name empty to delete port.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- scheduled wake -->
|
||||
<h5 class="fw-bold mt-4">Scheduled wake</h5>
|
||||
<p class="mb-2">Wake your device at a given time.</p>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="wake-disable"
|
||||
id="wake-radio-disabled-{modalDevice.id}"
|
||||
bind:group={modalDevice['wake']['enabled']}
|
||||
value={false}
|
||||
checked={!modalDevice['wake']['enabled']}
|
||||
/>
|
||||
<label class="form-check-label" for="wake-radio-disabled-{modalDevice.id}">
|
||||
Disabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="wake-cron"
|
||||
id="wake-radio-cron-{modalDevice.id}"
|
||||
bind:group={modalDevice['wake']['enabled']}
|
||||
value={true}
|
||||
checked={modalDevice['wake']['enabled']}
|
||||
/>
|
||||
<label class="form-check-label" for="wake-radio-cron-{modalDevice.id}">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group my-1" hidden={!modalDevice['wake']['enabled']}>
|
||||
<span class="input-group-text rounded-0 rounded-start" id="wake-cron-{modalDevice.id}"
|
||||
>Cron</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control rounded-0 rounded-end"
|
||||
placeholder="* /4 * * *"
|
||||
aria-label="Crontab"
|
||||
aria-describedby="wake-cron-{modalDevice.id}"
|
||||
bind:value={modalDevice['wake']['cron']}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#info-wake"
|
||||
aria-expanded="false"
|
||||
aria-controls="info-wake"
|
||||
>
|
||||
<i class="fa-solid fa-angle-down me-2" />How to use
|
||||
</button>
|
||||
<div class="collapse mt-3" id="info-wake">
|
||||
<div class="callout callout-info">
|
||||
<p class="mb-2">
|
||||
Cron is a syntax describing a time pattern when to execute jobs. The above field
|
||||
uses common cron syntax. Examples:
|
||||
</p>
|
||||
<pre class="mb-2">Minute Hour DayOfMonth Month DayOfWeek
|
||||
* /4 * * * (Wake every 4 hours)
|
||||
0 9 * * 1-5 (Wake from Mo-Fr at 9 a.m.)
|
||||
</pre>
|
||||
<p class="mb-0">
|
||||
Read more about <a
|
||||
href="https://linux.die.net/man/5/crontab"
|
||||
target="_blank"
|
||||
rel="noreferrer">valid syntax here</a
|
||||
>
|
||||
or
|
||||
<a href="https://crontab.guru/" target="_blank" rel="noreferrer"
|
||||
>use a generator</a
|
||||
>. Expressions starting with "@..." are not supported.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- scheduled shutdown -->
|
||||
<h5 class="fw-bold mt-4">Shutdown</h5>
|
||||
<p class="mb-2">
|
||||
Set the shutdown command here. This shell command will be executed when clicking the
|
||||
power button on the device card. You can use cron below, which will then execute the
|
||||
command at the given time.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<span
|
||||
class="input-group-text rounded-0 rounded-start"
|
||||
id="shutdown-command-{modalDevice.id}">Command</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control rounded-0 rounded-end"
|
||||
placeholder="sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo shutdown'"
|
||||
aria-label="Ccommand"
|
||||
aria-describedby="shutdown-command-{modalDevice.id}"
|
||||
bind:value={modalDevice['shutdown']['command']}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary mt-2"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#info-shutdown"
|
||||
aria-expanded="false"
|
||||
aria-controls="info-shutdown"
|
||||
>
|
||||
<i class="fa-solid fa-angle-down me-2" />How to use
|
||||
</button>
|
||||
<div class="collapse mt-3" id="info-shutdown">
|
||||
<div class="callout callout-info">
|
||||
<p class="mb-2">
|
||||
This field takes a shell command to trigger the shutdown. You can use <code
|
||||
>sshpass</code
|
||||
>
|
||||
for Linux or <code>net rpc</code> for Windows hosts.
|
||||
</p>
|
||||
<div class="callout callout-danger mb-2">
|
||||
Note: This command is safed as cleartext. Meaning, passwords are clearly visible
|
||||
in the database.
|
||||
</div>
|
||||
<p class="mb-2">Examples:</p>
|
||||
<pre class="mb-2"># wake linux hosts
|
||||
sshpass -p your_password ssh -o 'StrictHostKeyChecking=no' user@hostname 'sudo shutdown'
|
||||
|
||||
# wake windows hosts
|
||||
net rpc shutdown --ipaddress 192.168.1.1 --user user%password
|
||||
</pre>
|
||||
<p class="mb-0">
|
||||
Read more about <a
|
||||
href="https://linux.die.net/man/1/sshpass"
|
||||
target="_blank"
|
||||
rel="noreferrer">sshpass</a
|
||||
>
|
||||
or
|
||||
<a href="https://linux.die.net/man/8/net" target="_blank" rel="noreferrer"
|
||||
>net rpc</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label mt-2" for="{device.id}-shutdown-cron">Use cron</label>
|
||||
</div>
|
||||
<div class="form-check" id="{device.id}-shutdown-cron">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="shutdown-disable"
|
||||
id="shutdown-radio-disabled-{modalDevice.id}"
|
||||
bind:group={modalDevice['shutdown']['enabled']}
|
||||
value={false}
|
||||
checked={!modalDevice['shutdown']['enabled']}
|
||||
/>
|
||||
<label class="form-check-label" for="shutdown-radio-disabled-{modalDevice.id}">
|
||||
Disabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="shutdown-enable"
|
||||
id="shutdown-radio-enabled-{modalDevice.id}"
|
||||
bind:group={modalDevice['shutdown']['enabled']}
|
||||
value={true}
|
||||
checked={modalDevice['shutdown']['enabled']}
|
||||
/>
|
||||
<label class="form-check-label" for="shutdown-radio-enabled-{modalDevice.id}">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group my-1" hidden={!modalDevice['shutdown']['enabled']}>
|
||||
<span
|
||||
class="input-group-text rounded-0 rounded-start"
|
||||
id="shutdown-cron-{modalDevice.id}">Cron</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control rounded-0 rounded-end"
|
||||
placeholder="* /4 * * *"
|
||||
aria-label="Crontab"
|
||||
aria-describedby="shutdown-cron-{modalDevice.id}"
|
||||
bind:value={modalDevice['shutdown']['cron']}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
data-bs-dismiss="modal"
|
||||
on:click={deleteDevice}>Delete</button
|
||||
>
|
||||
<button type="submit" form="form-{modalDevice.id}" class="btn btn-outline-success"
|
||||
>Save changes</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
396
frontend/src/components/Navbar.svelte
Normal file
396
frontend/src/components/Navbar.svelte
Normal file
@@ -0,0 +1,396 @@
|
||||
<script>
|
||||
import socketStore from '@stores/socket';
|
||||
import DarkToggle from '@components/DarkToggle.svelte';
|
||||
export let visitors;
|
||||
export let settings;
|
||||
const pagetitle = import.meta.env.PAGE_TITLE ? import.meta.env.PAGE_TITLE : 'UpSnap';
|
||||
|
||||
let addDevice = {
|
||||
wake: {
|
||||
enabled: false,
|
||||
cron: ''
|
||||
},
|
||||
shutdown: {
|
||||
enabled: false,
|
||||
cron: '',
|
||||
command: ''
|
||||
}
|
||||
};
|
||||
|
||||
function updateDevice(data) {
|
||||
if (Object.keys(data).length < 4) {
|
||||
return;
|
||||
}
|
||||
socketStore.sendMessage({
|
||||
type: 'update_device',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
function updateSettings() {
|
||||
socketStore.sendMessage({
|
||||
type: 'update_settings',
|
||||
data: settings
|
||||
});
|
||||
hideModal('settings');
|
||||
}
|
||||
|
||||
function scanNetwork() {
|
||||
socketStore.sendMessage({
|
||||
type: 'scan_network'
|
||||
});
|
||||
const btnScan = document.querySelector('#btnScan');
|
||||
const btnScanSpinner = document.querySelector('#btnScanSpinner');
|
||||
const btnScanText = document.querySelector('#btnScanText');
|
||||
btnScan.disabled = true;
|
||||
btnScanSpinner.classList.remove('d-none');
|
||||
btnScanText.innerText = 'Scanning...';
|
||||
}
|
||||
|
||||
function addScan(i) {
|
||||
document.querySelector(`#btnAdd${i}`).disabled = true;
|
||||
const dev = settings.scan_network[i];
|
||||
updateDevice(dev);
|
||||
}
|
||||
|
||||
const restoreFromFile = (e) => {
|
||||
let file = e.target.files[0];
|
||||
let reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
reader.onload = (e) => {
|
||||
let data = JSON.parse(e.target.result);
|
||||
if (Array.isArray(data)) {
|
||||
// v2 file restore
|
||||
data.forEach((device) => {
|
||||
updateDevice(device);
|
||||
});
|
||||
} else {
|
||||
// v1 file restore
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
value['mac'] = key;
|
||||
value['link'] = '';
|
||||
updateDevice(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
hideModal('settings');
|
||||
};
|
||||
|
||||
function backupToFile() {
|
||||
socketStore.sendMessage({
|
||||
type: 'backup'
|
||||
});
|
||||
}
|
||||
|
||||
function hideModal(id) {
|
||||
const modalEl = document.querySelector(`#${id}`);
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
modal.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pagetitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<nav class="navbar navbar-expand-sm">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-brand" href="/">
|
||||
<img src="favicon.png" alt="Logo" width="24" height="24" class="me-2" />
|
||||
{pagetitle}
|
||||
</div>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon" />
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
|
||||
<span class="ms-auto d-flex">
|
||||
<DarkToggle />
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-light dropdown-toggle px-3 me-2 py-2"
|
||||
type="button"
|
||||
id="dropdownMenuButton1"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fa-solid fa-wrench me-2" />More
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
<li>
|
||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#addDevice">
|
||||
<i class="fa-solid fa-plus me-2" />Add device
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#settings">
|
||||
<i class="fa-solid fa-sliders me-2" />Settings
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn btn-light px-3 me-2 py-2 pe-none">
|
||||
<i class="me-2 fa-solid {visitors === 1 ? 'fa-user' : 'fa-user-group'}" />{visitors}
|
||||
{visitors === 1 ? 'Visitor' : 'Visitors'}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="addDevice"
|
||||
tabindex="-1"
|
||||
aria-labelledby="addDeviceLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold" id="addDeviceLabel">Add device</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addForm" on:submit|preventDefault={() => updateDevice(addDevice)}>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label for="inputNameAddDevice" class="form-label">Device name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNameAddDevice"
|
||||
placeholder="Max PC"
|
||||
bind:value={addDevice.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label for="inputMacAddDevice" class="form-label">Mac address</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputMacAddDevice"
|
||||
placeholder="aa:aa:aa:aa:aa:aa"
|
||||
bind:value={addDevice.mac}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label for="inputIpAddDevice" class="form-label">IP address</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputIpAddDevice"
|
||||
placeholder="192.168.1.1"
|
||||
bind:value={addDevice.ip}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
<label for="inputNetmaskAddDevice" class="form-label">Netmask</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNetmaskAddDevice"
|
||||
placeholder="255.255.255.0"
|
||||
bind:value={addDevice.netmask}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="mb-3">
|
||||
<label for="inputLinkAddDevice" class="form-label">Web link</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputILinkAddDevice"
|
||||
placeholder="http://...."
|
||||
bind:value={addDevice.link}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-auto ms-auto">
|
||||
<button type="submit" form="addForm" class="btn btn-outline-success"
|
||||
>Add device</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="fw-bold">Network discovery</h5>
|
||||
{#if !settings.discovery}
|
||||
<div class="callout callout-danger mb-2">
|
||||
<p class="m-0">
|
||||
To enable this option, please enter your network address in the settings.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
id="btnScan"
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
on:click={scanNetwork}
|
||||
disabled={!settings.discovery}
|
||||
>
|
||||
<span
|
||||
id="btnScanSpinner"
|
||||
class="spinner-grow spinner-grow-sm d-none"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span id="btnScanText">Scan</span>
|
||||
</button>
|
||||
{#if settings.scan_network?.length}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>IP</td>
|
||||
<td>Netmask</td>
|
||||
<td>Mac</td>
|
||||
<td>Add</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each settings.scan_network as device, i}
|
||||
<tr>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ip}</td>
|
||||
<td>{device.netmask}</td>
|
||||
<td>{device.mac}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
id="btnAdd{i}"
|
||||
class="btn btn-outline-secondary py-0"
|
||||
on:click={() => addScan(i)}
|
||||
>
|
||||
<i class="fa-solid fa-plus fa-sm" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="settings"
|
||||
tabindex="-1"
|
||||
aria-labelledby="settingsLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold" id="settingsLabel">Settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="settingsForm" on:submit|preventDefault={updateSettings}>
|
||||
<h5 class="fw-bold">General</h5>
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<div class="mb-3">
|
||||
<label for="inputNetworkDiscovery" class="form-label"
|
||||
>Network discovery address</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="inputNetworkDiscovery"
|
||||
placeholder="192.168.1.0/24"
|
||||
bind:value={settings.discovery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="mb-3">
|
||||
<label for="inputIntervalSettings" class="form-label">Interval (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
id="inputIntervalSettings"
|
||||
min="5"
|
||||
bind:value={settings.interval}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-check">
|
||||
<label class="form-check-label" for="flexCheckDefault">
|
||||
Enable notifications
|
||||
</label>
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value=""
|
||||
id="flexCheckDefault"
|
||||
bind:checked={settings.notifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row mb-3">
|
||||
<div class="col-auto ms-auto">
|
||||
<button type="submit" form="settingsForm" class="btn btn-outline-success">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="fw-bold">Backup/Restore</h5>
|
||||
<div class="callout callout-info mb-2">
|
||||
<p class="m-0">
|
||||
Backup file structure has changed in v2. You can still restore both versions with this
|
||||
file upload.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="inputRestore" class="form-label">Restore from .json</label>
|
||||
<input
|
||||
id="inputRestore"
|
||||
type="file"
|
||||
class="form-control"
|
||||
accept=".json"
|
||||
on:change={(e) => restoreFromFile(e)}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-secondary" on:click={backupToFile}>
|
||||
<i class="fa-solid fa-download me-2" />
|
||||
Export .json
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
23
frontend/src/components/Toast.svelte
Normal file
23
frontend/src/components/Toast.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
export let toast;
|
||||
</script>
|
||||
|
||||
<div class="position-fixed bottom-0 end-0 p-3 toast-container">
|
||||
<div
|
||||
class="toast fade {toast.show ? 'show' : 'hide'} {toast.color}-bg"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="toast-header">
|
||||
<strong id="toast-title" class="me-auto">{toast.title}</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close" />
|
||||
</div>
|
||||
<div class="toast-body fw-bold">
|
||||
{toast.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
188
frontend/src/main.scss
Normal file
188
frontend/src/main.scss
Normal file
@@ -0,0 +1,188 @@
|
||||
@import '../node_modules/bootstrap/scss/bootstrap';
|
||||
@import '../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss';
|
||||
@import '../node_modules/@fortawesome/fontawesome-free/scss/regular.scss';
|
||||
@import '../node_modules/@fortawesome/fontawesome-free/scss/solid.scss';
|
||||
|
||||
:root {
|
||||
--success: #{$success};
|
||||
--warning: #{$warning};
|
||||
--danger: #{$danger};
|
||||
--danger-dark-transparent: #{$danger-dark-transparent};
|
||||
--info: #{$info};
|
||||
--info-dark-transparent: #{$info-dark-transparent};
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
--color-bg: #{$light};
|
||||
--color-text: #{$dark};
|
||||
--bg-lighter: #{$light-darker};
|
||||
--bg-modal: #{$light};
|
||||
--svg-close: transparent
|
||||
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e")
|
||||
center/1em auto no-repeat;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
--color-bg: #{$dark};
|
||||
--color-text: #{$light};
|
||||
--bg-lighter: #{$dark-lighter};
|
||||
--bg-modal: #{$dark-lighter};
|
||||
--svg-close: transparent
|
||||
url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e")
|
||||
center/1em auto no-repeat;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-modal);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid var(--color-text);
|
||||
|
||||
.btn-close {
|
||||
background: var(--svg-close);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid var(--color-text);
|
||||
}
|
||||
|
||||
.btn,
|
||||
button {
|
||||
&.btn-light {
|
||||
color: var(--color-text);
|
||||
background-color: var(--bg-lighter);
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
&.btn-outline-success {
|
||||
border-color: $success;
|
||||
|
||||
&:hover {
|
||||
background-color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline-danger {
|
||||
border-color: $danger;
|
||||
|
||||
&:hover {
|
||||
background-color: $danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
&:focus {
|
||||
border-color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes on-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 $success;
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes off-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 $danger;
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-line;
|
||||
background-color: var(--color-bg);
|
||||
padding: 1em;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 2em;
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.hover {
|
||||
&:hover {
|
||||
text-shadow: 0px 0px 20px rgb(155, 155, 155);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-2x {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.fa-power-off {
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.fa-circle {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.inherit-color {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 1rem;
|
||||
border-left-width: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&.callout-info {
|
||||
background-color: var(--info-dark-transparent);
|
||||
border: 1px solid var(--info-dark-transparent);
|
||||
border-left: 5px solid var(--info);
|
||||
}
|
||||
|
||||
&.callout-danger {
|
||||
background-color: var(--danger-dark-transparent);
|
||||
border: 1px solid var(--danger-dark-transparent);
|
||||
border-left: 5px solid var(--danger);
|
||||
}
|
||||
}
|
||||
184
frontend/src/routes/+page.svelte
Normal file
184
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import socketStore from '@stores/socket';
|
||||
import Navbar from '@components/Navbar.svelte';
|
||||
import DeviceCard from '@components/DeviceCard.svelte';
|
||||
import Toast from '@components/Toast.svelte';
|
||||
|
||||
let visitors = 0;
|
||||
let devices = [];
|
||||
let settings = {};
|
||||
let toast = {
|
||||
title: '',
|
||||
message: '',
|
||||
color: '',
|
||||
show: false
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
socketStore.subscribeStatus((status) => {
|
||||
if (status == 'open') {
|
||||
showToast('Websocket', 'Connected', 'success');
|
||||
} else if (status == 'close') {
|
||||
showToast('Websocket', 'Connection closed. Trying to reconnect ...', 'danger');
|
||||
} else if (status == 'error') {
|
||||
showToast('Websocket', 'Error when connecting to websocket', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
socketStore.subscribeMsg((currentMessage) => {
|
||||
if (currentMessage.type == 'init') {
|
||||
// create devices
|
||||
devices = [...currentMessage.message.devices];
|
||||
devices = devices;
|
||||
devices.sort(compare);
|
||||
settings = currentMessage.message.settings;
|
||||
} else if (currentMessage.type == 'status') {
|
||||
// set device array and sort
|
||||
const index = devices.findIndex((x) => x.id == currentMessage.message.id);
|
||||
if (devices.length === 0 || index === -1) {
|
||||
devices.push(currentMessage.message);
|
||||
devices = devices;
|
||||
} else {
|
||||
devices[index] = currentMessage.message;
|
||||
}
|
||||
devices.sort(compare);
|
||||
// set device status
|
||||
if (currentMessage.message.up == true) {
|
||||
setUp(currentMessage.message);
|
||||
} else {
|
||||
setDown(currentMessage.message);
|
||||
}
|
||||
} else if (currentMessage.type == 'pending') {
|
||||
// set device pending
|
||||
setPending(currentMessage.message);
|
||||
} else if (currentMessage.type == 'visitor') {
|
||||
// update visitor count
|
||||
visitors = currentMessage.message;
|
||||
} else if (currentMessage.type == 'delete') {
|
||||
// delete device
|
||||
const devCol = document.querySelector(`#device-col-${currentMessage.message}`);
|
||||
devCol.remove();
|
||||
} else if (currentMessage.type == 'scan_network') {
|
||||
// set scanned network devices
|
||||
if (!currentMessage.message) {
|
||||
return;
|
||||
}
|
||||
settings['scan_network'] = currentMessage.message;
|
||||
const btnScan = document.querySelector('#btnScan');
|
||||
const btnScanSpinner = document.querySelector('#btnScanSpinner');
|
||||
const btnScanText = document.querySelector('#btnScanText');
|
||||
btnScan.disabled = false;
|
||||
btnScanSpinner.classList.add('d-none');
|
||||
btnScanText.innerText = 'Scan';
|
||||
} else if (currentMessage.type == 'backup') {
|
||||
// download backup file
|
||||
const now = new Date();
|
||||
const fileName = `upsnap_backup_${now.toISOString()}.json`;
|
||||
const a = document.createElement('a');
|
||||
const file = new Blob([JSON.stringify(currentMessage.message)], { type: 'text/plain' });
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
} else if (currentMessage.type == 'operationStatus') {
|
||||
if (currentMessage.message == 'Success') {
|
||||
showToast(currentMessage.message, 'Changes were saved', 'success');
|
||||
} else if (currentMessage.message == 'Error') {
|
||||
showToast(
|
||||
currentMessage.message,
|
||||
'Error while saving the device. Please check the logs.',
|
||||
'danger'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setUp(device) {
|
||||
const dot = document.querySelector(`#dot-${device.id}`);
|
||||
const spinner = document.querySelector(`#spinner-${device.id}`);
|
||||
if (dot) {
|
||||
if (dot.classList.contains('danger')) {
|
||||
showToast(device.name, 'Device is up!', 'success');
|
||||
}
|
||||
dot.style.animation = 'none';
|
||||
dot.offsetWidth;
|
||||
if (!spinner.classList.contains('d-none')) {
|
||||
spinner.classList.add('d-none');
|
||||
dot.classList.remove('d-none', 'danger');
|
||||
dot.classList.add('success');
|
||||
} else {
|
||||
dot.style.animation = 'on-pulse 1s normal';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDown(device) {
|
||||
const dot = document.querySelector(`#dot-${device.id}`);
|
||||
const spinner = document.querySelector(`#spinner-${device.id}`);
|
||||
if (dot) {
|
||||
if (dot.classList.contains('success')) {
|
||||
showToast(device.name, 'Device is down!', 'danger');
|
||||
}
|
||||
dot.style.animation = 'none';
|
||||
dot.offsetWidth;
|
||||
if (!spinner.classList.contains('d-none')) {
|
||||
spinner.classList.add('d-none');
|
||||
dot.classList.remove('d-none', 'success');
|
||||
dot.classList.add('danger');
|
||||
} else {
|
||||
dot.style.animation = 'off-pulse 1s normal';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPending(id) {
|
||||
const dot = document.querySelector(`#dot-${id}`);
|
||||
const spinner = document.querySelector(`#spinner-${id}`);
|
||||
dot.classList.add('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function showToast(title, message, color) {
|
||||
if (settings.notifications === false) {
|
||||
return;
|
||||
}
|
||||
toast.title = title;
|
||||
toast.message = message;
|
||||
toast.color = color;
|
||||
toast.show = true;
|
||||
setTimeout(() => {
|
||||
toast.show = false;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function compare(a, b) {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar {settings} {visitors} />
|
||||
<div class="container mb-3">
|
||||
<div class="row">
|
||||
{#each devices as device}
|
||||
<DeviceCard {device} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Toast {toast} />
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
// @import "../main.scss";
|
||||
</style>
|
||||
60
frontend/src/stores/socket.ts
Normal file
60
frontend/src/stores/socket.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const BACKEND_IS_PROXIED = import.meta.env.VITE_BACKEND_IS_PROXIED;
|
||||
const BACKEND_PORT = import.meta.env.VITE_BACKEND_PORT;
|
||||
|
||||
const status = writable('');
|
||||
const message = writable('');
|
||||
|
||||
let socket: WebSocket;
|
||||
|
||||
function initSocket() {
|
||||
if (BACKEND_IS_PROXIED) {
|
||||
const socketUrl = new URL('ws/wol', window.location.href);
|
||||
socketUrl.protocol = socketUrl.protocol.replace('http', 'ws');
|
||||
socket = new WebSocket(socketUrl);
|
||||
} else {
|
||||
socket = new WebSocket(`ws://${location.hostname}:${BACKEND_PORT}/ws/wol`);
|
||||
}
|
||||
|
||||
// Connection opened
|
||||
socket.addEventListener('open', function () {
|
||||
status.set('open');
|
||||
});
|
||||
|
||||
// Connection closed
|
||||
socket.addEventListener('close', function () {
|
||||
status.set('close');
|
||||
setTimeout(function () {
|
||||
initSocket();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Connection error
|
||||
socket.addEventListener('error', function () {
|
||||
status.set('error');
|
||||
socket.close();
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
socket.addEventListener('message', function (event) {
|
||||
message.set(JSON.parse(event.data));
|
||||
});
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
initSocket();
|
||||
}
|
||||
|
||||
const sendMessage = (message: string | object) => {
|
||||
if (socket.readyState <= 1) {
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
subscribeMsg: message.subscribe,
|
||||
subscribeStatus: status.subscribe,
|
||||
sendMessage
|
||||
};
|
||||
@@ -26,4 +26,4 @@ $toast-box-shadow: none;
|
||||
$toast-border-width: 0px;
|
||||
|
||||
// font awesome
|
||||
$fa-font-path: "webfonts";
|
||||
$fa-font-path: 'webfonts';
|
||||
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
20
frontend/svelte.config.js
Normal file
20
frontend/svelte.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
import path from 'path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
'@components': path.resolve('./src/components'),
|
||||
'@stores': path.resolve('./src/stores')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
3
frontend/tsconfig.json
Normal file
3
frontend/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json"
|
||||
}
|
||||
8
frontend/vite.config.js
Normal file
8
frontend/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const config = {
|
||||
plugins: [sveltekit()]
|
||||
};
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user