sveltekit and go fiber+gorm

This commit is contained in:
Maxi Quoß
2022-12-19 15:19:09 +01:00
parent c0b055ed1b
commit 49f3ff78b2
89 changed files with 9138 additions and 6201 deletions

View File

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

1
.gitignore vendored
View File

@@ -292,3 +292,4 @@ staticfiles
# custom # custom
docker-compose.yml docker-compose.yml
db/ db/
*.db

View File

@@ -1,36 +0,0 @@
FROM debian:bullseye-slim as base
FROM base as python-build
ENV PYTHONUNBUFFERED 1
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /python-build
RUN apt-get update &&\
apt-get install -y --no-install-recommends build-essential libffi-dev libssl-dev cargo python3 python3-dev python3-pip python3-venv default-libmysqlclient-dev libpq-dev &&\
python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY app/backend/requirements.txt .
RUN python3 -m pip install --no-cache-dir --upgrade pip wheel &&\
pip install --no-cache-dir -r requirements.txt
FROM node:17-bullseye-slim as node-build
WORKDIR /node-build
COPY app/frontend/package*.json ./
COPY app/frontend/src ./src
COPY app/frontend/public ./public
COPY app/frontend/rollup.config.js ./
FROM base
WORKDIR /app
ENV PYTHONUNBUFFERED 1
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update &&\
apt-get install -y --no-install-recommends default-mysql-client nodejs npm iputils-ping nmap samba-common-bin openssh-client sshpass curl &&\
apt-get clean &&\
rm -rf /var/lib/{apt,dpkg,cache,log}/
COPY --from=python-build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=node-build /node-build ./frontend
COPY app/backend ./backend
COPY app/run.sh ./
CMD ["./run.sh"]

View File

@@ -1,26 +0,0 @@
"""
ASGI config for backend project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from wol.routing import ws_urlpatterns
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(URLRouter(ws_urlpatterns))
})

View File

@@ -1,10 +0,0 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
app = Celery("dango_wol")
app.conf.timezone = os.getenv("DJANGO_TIME_ZONE", "UTC")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

View File

@@ -1,177 +0,0 @@
"""
Django settings for backend project.
Generated by 'django-admin startproject' using Django 4.0.2.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
import os
from django.core.management.utils import get_random_secret_key
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'django_celery_beat',
'wol'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'backend.wsgi.application'
WSGI_APPLICATION = 'backend.wsgi.application'
ASGI_APPLICATION = 'backend.asgi.application'
CELERY_BROKER_URL = f"redis://{os.getenv('REDIS_HOST', '127.0.0.1')}:{os.getenv('REDIS_PORT', 6379)}"
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
if os.getenv("DB_TYPE") == "postgres":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": os.getenv("DB_NAME", "upsnap"),
"USER": os.getenv("DB_USER", "upsnap"),
"PASSWORD": os.getenv("DB_PASSWORD", "upsnap"),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", 5432),
"OPTIONS": {
"connect_timeout": 30
}
}
}
elif os.getenv("DB_TYPE") == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "upsnap"),
"USER": os.getenv("DB_USER", "upsnap"),
"PASSWORD": os.getenv("DB_PASSWORD", "upsnap"),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", 3306),
"OPTIONS": {
"connect_timeout": 30
}
}
}
elif os.getenv("DB_TYPE") == "sqlite":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/app/backend/db/db.sqlite3",
}
}
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(os.getenv("REDIS_HOST", "127.0.0.1"), os.getenv("REDIS_PORT", 6379))],
"capacity": 1000,
"expiry": 10
}
}
}
# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/
LANGUAGE_CODE = os.getenv("DJANGO_LANGUAGE_CODE", "en")
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC")
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
VERSION = "v2.3.1"

View File

@@ -1,23 +0,0 @@
"""backend URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls)
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -1,16 +0,0 @@
"""
WSGI config for backend project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
application = get_wsgi_application()

View File

@@ -1,22 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

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

View File

@@ -1,51 +0,0 @@
import os
from django.contrib.auth.models import User
from django_celery_beat.models import IntervalSchedule, PeriodicTask, PeriodicTasks
from wol.models import Settings, Websocket
# create superuser
if os.getenv("DJANGO_SUPERUSER_USER") and os.getenv("DJANGO_SUPERUSER_PASSWORD"):
if not User.objects.filter(username=os.getenv("DJANGO_SUPERUSER_USER")).exists():
User.objects.create_superuser(os.getenv("DJANGO_SUPERUSER_USER"), password=os.getenv("DJANGO_SUPERUSER_PASSWORD"))
else:
print('Django user exists')
# reset visitors
[i.delete() for i in Websocket.objects.all()]
Websocket.objects.create(visitors=0)
# ping interval
if os.environ.get("PING_INTERVAL"):
if int(os.environ.get("PING_INTERVAL")) < 5:
print("Ping interval lower than 5 seconds is not recommended. Please use an interval of 5 seconds or higher. Automatically set to 5 seconds.")
ping_interval = 5
else:
ping_interval = int(os.environ.get("PING_INTERVAL"))
else:
ping_interval = 5
Settings.objects.update_or_create(
id=1,
defaults={
"interval": ping_interval
}
)
# register device ping task
schedule, created = IntervalSchedule.objects.get_or_create(
every=ping_interval,
period=IntervalSchedule.SECONDS,
)
if created:
PeriodicTask.objects.create(
interval=schedule,
name="Ping all devices",
task="wol.tasks.ping_all_devices"
)
# reset last run to fix time zone changes
# https://django-celery-beat.readthedocs.io/en/latest/#important-warning-about-time-zones
PeriodicTask.objects.update(last_run_at=None)
for task in PeriodicTask.objects.all():
PeriodicTasks.changed(task)

View File

@@ -1,25 +0,0 @@
from django.contrib import admin
from wol.models import Device, Port, Settings
class DeviceAdmin(admin.ModelAdmin):
list_display = ["name", "ip", "mac", "netmask", "ports"]
search_fields = ["name", "ip", "mac"]
list_filter = ["name", "netmask"]
def ports(self, obj):
return ", ".join([p.name for p in obj.port.all()])
class PortAdmin(admin.ModelAdmin):
list_display = ["number", "name"]
search_fields = ["number", "name"]
list_filter = ["number", "name"]
class SettingsAdmin(admin.ModelAdmin):
list_display = ["sort_by", "scan_address", "interval"]
admin.site.register(Device, DeviceAdmin)
admin.site.register(Port, PortAdmin)
admin.site.register(Settings, SettingsAdmin)

View File

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

View File

@@ -1,12 +0,0 @@
import ipaddress
import wakeonlan
import subprocess
def wake(mac, ip, netmask):
subnet = ipaddress.ip_network(
f"{ip}/{netmask}", strict=False).broadcast_address
wakeonlan.send_magic_packet(mac, ip_address=str(subnet))
def shutdown(command):
subprocess.run(command, shell=True)

View File

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

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.1.3 on 2020-11-15 19:37
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Device',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(max_length=100)),
('ip', models.GenericIPAddressField()),
('mac', models.SlugField(max_length=17)),
],
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.1.3 on 2020-11-15 19:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='device',
name='mac',
field=models.CharField(max_length=17),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.1.3 on 2020-11-15 22:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0002_auto_20201115_2043'),
]
operations = [
migrations.AddField(
model_name='device',
name='status',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 3.1.3 on 2020-11-16 02:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('wol', '0003_device_status'),
]
operations = [
migrations.RemoveField(
model_name='device',
name='status',
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.1.5 on 2021-01-18 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0004_remove_device_status'),
]
operations = [
migrations.AddField(
model_name='device',
name='netmask',
field=models.CharField(default='255.255.255.0', max_length=15),
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-22 20:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0005_device_netmask'),
]
operations = [
migrations.CreateModel(
name='Websocket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('visitors', models.PositiveSmallIntegerField(default=0)),
],
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-23 16:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0006_websocket'),
]
operations = [
migrations.AddField(
model_name='device',
name='scheduled_wake',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-27 16:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0007_device_scheduled_wake'),
]
operations = [
migrations.CreateModel(
name='Settings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ping_interval', models.PositiveSmallIntegerField(default=5)),
('enable_notifications', models.BooleanField(default=True)),
('enable_console_logging', models.BooleanField(default=True)),
('sort_by', models.SlugField(default='name')),
],
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-27 19:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('wol', '0008_settings'),
]
operations = [
migrations.RemoveField(
model_name='settings',
name='ping_interval',
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-28 19:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0009_remove_settings_ping_interval'),
]
operations = [
migrations.AlterField(
model_name='settings',
name='enable_console_logging',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-28 20:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0010_alter_settings_enable_console_logging'),
]
operations = [
migrations.AddField(
model_name='settings',
name='scan_address',
field=models.GenericIPAddressField(blank=True, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.7 on 2021-09-28 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0011_settings_scan_address'),
]
operations = [
migrations.AlterField(
model_name='device',
name='name',
field=models.SlugField(default='Unknown', max_length=100),
),
]

View File

@@ -1,64 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-12 10:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wol', '0012_alter_device_name'),
]
operations = [
migrations.CreateModel(
name='Port',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.PositiveIntegerField()),
('name', models.SlugField()),
],
),
migrations.RenameField(
model_name='settings',
old_name='enable_notifications',
new_name='notifications',
),
migrations.RemoveField(
model_name='device',
name='scheduled_wake',
),
migrations.RemoveField(
model_name='settings',
name='enable_console_logging',
),
migrations.AddField(
model_name='device',
name='shutdown_cmd',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='settings',
name='interval',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='device',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='settings',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='websocket',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AddField(
model_name='device',
name='port',
field=models.ManyToManyField(blank=True, to='wol.Port'),
),
]

View File

@@ -1,27 +0,0 @@
from django.db import models
class Port(models.Model):
number = models.PositiveIntegerField()
name = models.SlugField()
def __str__(self) -> str:
return self.name
class Device(models.Model):
name = models.SlugField(default="Unknown", max_length=100)
ip = models.GenericIPAddressField()
mac = models.CharField(max_length=17)
netmask = models.CharField(max_length=15, default="255.255.255.0", blank=False, null=False)
link = models.URLField(blank=True, null=True)
port = models.ManyToManyField(Port, blank=True)
shutdown_cmd = models.TextField(null=True, blank=True)
class Websocket(models.Model):
visitors = models.PositiveSmallIntegerField(blank=False, null=False, default=0)
class Settings(models.Model):
sort_by = models.SlugField(default="name")
scan_address = models.GenericIPAddressField(null=True, blank=True)
interval = models.PositiveSmallIntegerField(null=True, blank=True)
notifications = models.BooleanField(default=True)

View File

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

View File

@@ -1,148 +0,0 @@
import socket
import subprocess
import threading
from asgiref.sync import async_to_sync
from celery import shared_task
from channels.layers import get_channel_layer
from django.db import connection
from django_celery_beat.models import CrontabSchedule, PeriodicTask
from wol.commands import shutdown, wake
from wol.models import Device, Port, Websocket
channel_layer = get_channel_layer()
class WolDevice:
def ping_device(self, ip):
try:
subprocess.check_output(["ping", "-c", "1", "-W", "0.5", ip])
return True
except subprocess.CalledProcessError:
return False
def check_port(self, ip, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
if sock.connect_ex((ip, port)) == 0:
return True
return False
def start(self, dev):
data = {
"id": dev.id,
"name": dev.name,
"ip": dev.ip,
"mac": dev.mac,
"netmask": dev.netmask,
"up": False,
"link": dev.link,
"ports": [],
"wake": {
"enabled": False,
"cron": ""
},
"shutdown": {
"enabled": False,
"cron": "",
"command": dev.shutdown_cmd
}
}
# add ports
for p in Port.objects.all().order_by("number"):
data["ports"].append({
"number": p.number,
"name": p.name,
"checked": True if p in dev.port.all() else False,
"open": False
})
# set status for device and ports
if self.ping_device(dev.ip):
data["up"] = True
for port in dev.port.all():
index = next(i for i, d in enumerate(
data["ports"]) if d["number"] == port.number)
if self.check_port(dev.ip, port.number):
data["ports"][index]["checked"] = True
data["ports"][index]["open"] = True
else:
data["ports"][index]["checked"] = True
data["ports"][index]["open"] = False
# set cron for wake and shutdown
for action in ["wake", "shutdown"]:
try:
task = PeriodicTask.objects.filter(
name=f"{data['name']}-{action}",
task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
if task:
wake = CrontabSchedule.objects.get(id=task.crontab_id)
data[action]["enabled"] = task.enabled
data[action]["cron"] = " ".join(
[wake.minute, wake.hour, wake.day_of_week, wake.day_of_month, wake.month_of_year])
except PeriodicTask.DoesNotExist:
pass
async_to_sync(channel_layer.group_send)(
"wol", {"type": "send_group", "message": {
"type": "status",
"message": data
}})
connection.close()
@shared_task
def ping_all_devices():
if Websocket.objects.first().visitors == 0:
return
devices = Device.objects.all()
for dev in devices:
d = WolDevice()
t = threading.Thread(target=d.start, args=(dev,))
t.start()
@shared_task
def scheduled_wake(id):
try:
device = Device.objects.get(id=id)
except Device.DoesNotExist:
for task in PeriodicTask.objects.filter(args=id):
task.delete()
return
d = WolDevice()
up = d.ping_device(device.ip)
if not up:
wake(device.mac, device.ip, device.netmask)
async_to_sync(channel_layer.group_send)(
"wol", {"type": "send_group", "message": {
"type": "pending",
"message": id
}})
@shared_task
def scheduled_shutdown(id):
try:
device = Device.objects.get(id=id)
except Device.DoesNotExist:
for task in PeriodicTask.objects.filter(args=id):
task.delete()
return
d = WolDevice()
up = d.ping_device(device.ip)
if up:
shutdown(device.shutdown_cmd)
async_to_sync(channel_layer.group_send)(
"wol", {"type": "send_group", "message": {
"type": "pending",
"message": id
}})

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
*Psst — looking for a more complete solution? Check out [SvelteKit](https://kit.svelte.dev), the official framework for building web applications of all sizes, with a beautiful development experience and flexible filesystem-based routing.*
*Looking for a shareable component template instead? You can [use SvelteKit for that as well](https://kit.svelte.dev/docs#packaging) or the older [sveltejs/component-template](https://github.com/sveltejs/component-template)*
---
# svelte app
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
```bash
npx degit sveltejs/template svelte-app
cd svelte-app
```
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
## Get started
Install the dependencies...
```bash
cd svelte-app
npm install
```
...then start [Rollup](https://rollupjs.org):
```bash
npm run dev
```
Navigate to [localhost:8080](http://localhost:8080). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.
By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`.
If you're using [Visual Studio Code](https://code.visualstudio.com/) we recommend installing the official extension [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using other editors you may need to install a plugin in order to get syntax highlighting and intellisense.
## Building and running in production mode
To create an optimised version of the app:
```bash
npm run build
```
You can run the newly built app with `npm run start`. This uses [sirv](https://github.com/lukeed/sirv), which is included in your package.json's `dependencies` so that the app will work when you deploy to platforms like [Heroku](https://heroku.com).
## Single-page app mode
By default, sirv will only respond to requests that match files in `public`. This is to maximise compatibility with static fileservers, allowing you to deploy your app anywhere.
If you're building a single-page app (SPA) with multiple routes, sirv needs to be able to respond to requests for *any* path. You can make it so by editing the `"start"` command in package.json:
```js
"start": "sirv public --single"
```
## Using TypeScript
This template comes with a script to set up a TypeScript development environment, you can run it immediately after cloning the template with:
```bash
node scripts/setupTypeScript.js
```
Or remove the script via:
```bash
rm scripts/setupTypeScript.js
```
If you want to use `baseUrl` or `path` aliases within your `tsconfig`, you need to set up `@rollup/plugin-alias` to tell Rollup to resolve the aliases. For more info, see [this StackOverflow question](https://stackoverflow.com/questions/63427935/setup-tsconfig-path-in-svelte).
## Deploying to the web
### With [Vercel](https://vercel.com)
Install `vercel` if you haven't already:
```bash
npm install -g vercel
```
Then, from within your project folder:
```bash
cd public
vercel deploy --name my-project
```
### With [surge](https://surge.sh/)
Install `surge` if you haven't already:
```bash
npm install -g surge
```
Then, from within your project folder:
```bash
npm run build
surge public my-project.surge.sh
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
{
"name": "svelte-app",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public --no-clear --host"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.0",
"rollup": "^2.3.4",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0",
"svelte": "^3.0.0"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.0.0",
"@popperjs/core": "^2.11.5",
"@rollup/plugin-replace": "^4.0.0",
"bootstrap": "^5.1.3",
"dotenv": "^16.0.0",
"postcss": "^8.4.6",
"sass": "^1.49.9",
"sirv-cli": "^2.0.0",
"svelte-preprocess": "^4.10.3"
}
}

View File

@@ -1,19 +0,0 @@
main.svelte-1tky8bj {
text-align: center;
padding: 1em;
max-width: 240px;
margin: 0 auto;
}
h1.svelte-1tky8bj {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}
@media (min-width: 640px) {
main.svelte-1tky8bj {
max-width: none;
}
}

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<link rel='icon' type='image/png' href='favicon.png'>
<link rel='stylesheet' href='global.css'>
<link rel='stylesheet' href='build/bundle.css'>
<script defer src='build/bundle.js'></script>
<script defer src='build/popper.min.js'></script>
<script defer src='build/bootstrap.min.js'></script>
</head>
<body>
</body>
</html>

View File

@@ -1,103 +0,0 @@
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import css from 'rollup-plugin-css-only';
import preprocess from 'svelte-preprocess';
import copy from 'rollup-plugin-copy';
import dotenv from "dotenv";
import replace from '@rollup/plugin-replace';
dotenv.config()
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'],
shell: true
});
process.on('SIGTERM', toExit);
process.on('exit', toExit);
}
};
}
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js'
},
plugins: [
svelte({
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
preprocess: preprocess(),
}),
replace({
BACKEND_PORT: JSON.stringify(process.env.BACKEND_PORT),
BACKEND_IS_PROXIED: JSON.stringify(process.env.BACKEND_IS_PROXIED.toLowerCase() === 'true'),
PAGE_TITLE: JSON.stringify(process.env.PAGE_TITLE),
preventAssignment: true
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: 'bundle.css' }),
copy({
targets: [
{
src: [
"node_modules/@fortawesome/fontawesome-free/webfonts",
"node_modules/bootstrap/dist/js/bootstrap.min.js",
"node_modules/bootstrap/dist/js/bootstrap.min.js.map",
"node_modules/@popperjs/core/dist/umd/popper.min.js",
"node_modules/@popperjs/core/dist/umd/popper.min.js.map"
],
dest: "public/build/"
}
]
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};

View File

@@ -1,121 +0,0 @@
// @ts-check
/** This script modifies the project to support TS code in .svelte files like:
<script lang="ts">
export let name: string;
</script>
As well as validating the code for CI.
*/
/** To work on this script:
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
*/
const fs = require("fs")
const path = require("path")
const { argv } = require("process")
const projectRoot = argv[2] || path.join(__dirname, "..")
// Add deps to pkg.json
const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
"svelte-check": "^2.0.0",
"svelte-preprocess": "^4.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"typescript": "^4.0.0",
"tslib": "^2.0.0",
"@tsconfig/svelte": "^2.0.0"
})
// Add script for checking
packageJSON.scripts = Object.assign(packageJSON.scripts, {
"check": "svelte-check --tsconfig ./tsconfig.json"
})
// Write the package JSON
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " "))
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
const beforeMainJSPath = path.join(projectRoot, "src", "main.js")
const afterMainTSPath = path.join(projectRoot, "src", "main.ts")
fs.renameSync(beforeMainJSPath, afterMainTSPath)
// Switch the app.svelte file to use TS
const appSveltePath = path.join(projectRoot, "src", "App.svelte")
let appFile = fs.readFileSync(appSveltePath, "utf8")
appFile = appFile.replace("<script>", '<script lang="ts">')
appFile = appFile.replace("export let name;", 'export let name: string;')
fs.writeFileSync(appSveltePath, appFile)
// Edit rollup config
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
// Edit imports
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';`)
// Replace name of entry point
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
// Add preprocessor
rollupConfig = rollupConfig.replace(
'compilerOptions:',
'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:'
);
// Add TypeScript
rollupConfig = rollupConfig.replace(
'commonjs(),',
'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),'
);
fs.writeFileSync(rollupConfigPath, rollupConfig)
// Add TSConfig
const tsconfig = `{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}`
const tsconfigPath = path.join(projectRoot, "tsconfig.json")
fs.writeFileSync(tsconfigPath, tsconfig)
// Add global.d.ts
const dtsPath = path.join(projectRoot, "src", "global.d.ts")
fs.writeFileSync(dtsPath, `/// <reference types="svelte" />`)
// Delete this script, but not during testing
if (!argv[2]) {
// Remove the script
fs.unlinkSync(path.join(__filename))
// Check for Mac's DS_store file, and if it's the only one left remove it
const remainingFiles = fs.readdirSync(path.join(__dirname))
if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {
fs.unlinkSync(path.join(__dirname, '.DS_store'))
}
// Check if the scripts folder is empty
if (fs.readdirSync(path.join(__dirname)).length === 0) {
// Remove the scripts folder
fs.rmdirSync(path.join(__dirname))
}
}
// Adds the extension recommendation
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })
fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{
"recommendations": ["svelte.svelte-vscode"]
}
`)
console.log("Converted to TypeScript.")
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
console.log("\nYou will need to re-run your dependency manager to get started.")
}

View File

@@ -1,302 +0,0 @@
<script>
import { onMount } from "svelte";
import socketStore from "./socketStore.js";
import Navbar from "./components/Navbar.svelte";
import DeviceCard from "./components/DeviceCard.svelte";
import Toast from "./components/Toast.svelte";
let visitors = 0;
let devices = [];
let settings = {};
let toast = {
title: "",
message: "",
color: "",
show: false,
};
onMount(() => {
socketStore.subscribeStatus((status) => {
if (status == "open") {
showToast("Websocket", "Connected", "success");
} else if (status == "close") {
showToast(
"Websocket",
"Connection closed. Trying to reconnect ...",
"danger"
);
} else if (status == "error") {
showToast(
"Websocket",
"Error when connecting to websocket",
"danger"
);
}
});
socketStore.subscribeMsg((currentMessage) => {
if (currentMessage.type == "init") {
// create devices
devices = [...currentMessage.message.devices];
devices = devices;
devices.sort(compare);
settings = currentMessage.message.settings;
} else if (currentMessage.type == "status") {
// set device array and sort
const index = devices.findIndex(
(x) => x.id == currentMessage.message.id
);
if (devices.length === 0 || index === -1) {
devices.push(currentMessage.message);
devices = devices;
} else {
devices[index] = currentMessage.message;
}
devices.sort(compare);
// set device status
if (currentMessage.message.up == true) {
setUp(currentMessage.message);
} else {
setDown(currentMessage.message);
}
} else if (currentMessage.type == "pending") {
// set device pending
setPending(currentMessage.message);
} else if (currentMessage.type == "visitor") {
// update visitor count
visitors = currentMessage.message;
} else if (currentMessage.type == "delete") {
// delete device
const devCol = document.querySelector(
`#device-col-${currentMessage.message}`
);
devCol.remove();
} else if (currentMessage.type == "scan_network") {
// set scanned network devices
if (!currentMessage.message) {
return;
}
settings["scan_network"] = currentMessage.message;
const btnScan = document.querySelector("#btnScan");
const btnScanSpinner =
document.querySelector("#btnScanSpinner");
const btnScanText = document.querySelector("#btnScanText");
btnScan.disabled = false;
btnScanSpinner.classList.add("d-none");
btnScanText.innerText = "Scan";
} else if (currentMessage.type == "backup") {
// download backup file
const now = new Date();
const fileName = `upsnap_backup_${now.toISOString()}.json`;
const a = document.createElement("a");
const file = new Blob(
[JSON.stringify(currentMessage.message)],
{ type: "text/plain" }
);
a.href = URL.createObjectURL(file);
a.download = fileName;
a.click();
} else if (currentMessage.type == "operationStatus") {
if (currentMessage.message == "Success") {
showToast(
currentMessage.message,
"Changes were saved",
"success"
);
} else if (currentMessage.message == "Error") {
showToast(
currentMessage.message,
"Error while saving the device. Please check the logs.",
"danger"
);
}
}
});
});
function setUp(device) {
const dot = document.querySelector(`#dot-${device.id}`);
const spinner = document.querySelector(`#spinner-${device.id}`);
if (dot) {
if (dot.classList.contains("danger")) {
showToast(device.name, "Device is up!", "success");
}
dot.style.animation = "none";
dot.offsetWidth;
if (!spinner.classList.contains("d-none")) {
spinner.classList.add("d-none");
dot.classList.remove("d-none", "danger");
dot.classList.add("success");
} else {
dot.style.animation = "on-pulse 1s normal";
}
}
}
function setDown(device) {
const dot = document.querySelector(`#dot-${device.id}`);
const spinner = document.querySelector(`#spinner-${device.id}`);
if (dot) {
if (dot.classList.contains("success")) {
showToast(device.name, "Device is down!", "danger");
}
dot.style.animation = "none";
dot.offsetWidth;
if (!spinner.classList.contains("d-none")) {
spinner.classList.add("d-none");
dot.classList.remove("d-none", "success");
dot.classList.add("danger");
} else {
dot.style.animation = "off-pulse 1s normal";
}
}
}
function setPending(id) {
const dot = document.querySelector(`#dot-${id}`);
const spinner = document.querySelector(`#spinner-${id}`);
dot.classList.add("d-none");
spinner.classList.remove("d-none");
}
function showToast(title, message, color) {
if (settings.notifications === false) {
return;
}
toast.title = title;
toast.message = message;
toast.color = color;
toast.show = true;
setTimeout(() => {
toast.show = false;
}, 4000);
}
function compare(a, b) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
</script>
<main>
<Navbar {settings} {visitors} />
<div class="container mb-3">
<div class="row">
{#each devices as device}
<DeviceCard {device} />
{/each}
</div>
</div>
<Toast {toast} />
</main>
<style lang="scss" global>
@import "./variables.scss";
@import "../node_modules/bootstrap/scss/bootstrap";
@import "../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "../node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
@import "../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
@import "theme.scss";
body {
color: var(--color-text);
background-color: var(--color-bg);
}
.modal-content {
background-color: var(--bg-modal);
}
.modal-header {
border-bottom: 1px solid var(--color-text);
.btn-close {
background: var(--svg-close);
}
}
.modal-footer {
border-top: 1px solid var(--color-text);
}
.btn,
button {
&.btn-light {
color: var(--color-text);
background-color: var(--bg-lighter);
border-width: 0px;
}
&.btn-outline-success {
border-color: $success;
&:hover {
background-color: $success;
}
}
&.btn-outline-danger {
border-color: $danger;
&:hover {
background-color: $danger;
}
}
}
.form-control {
&:focus {
border-color: inherit;
box-shadow: none;
}
}
.callout {
padding: 1rem;
border-left-width: 0.25rem;
border-radius: 0.25rem;
&.callout-info {
background-color: var(--info-dark-transparent);
border: 1px solid var(--info-dark-transparent);
border-left: 5px solid var(--info);
}
&.callout-danger {
background-color: var(--danger-dark-transparent);
border: 1px solid var(--danger-dark-transparent);
border-left: 5px solid var(--danger);
}
}
@keyframes on-pulse {
0% {
box-shadow: 0 0 0 0 $success;
}
70% {
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
@keyframes off-pulse {
0% {
box-shadow: 0 0 0 0 $danger;
}
70% {
box-shadow: 0 0 0 15px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
<script>
export let toast;
</script>
<div class="position-fixed bottom-0 end-0 p-3 toast-container">
<div
class="toast fade {toast.show ? 'show' : 'hide'} {toast.color}-bg"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div class="toast-header">
<strong id="toast-title" class="me-auto">{toast.title}</strong>
<button
type="button"
class="btn-close"
data-bs-dismiss="toast"
aria-label="Close"
/>
</div>
<div class="toast-body fw-bold">
{toast.message}
</div>
</div>
</div>
<style lang="scss">
@import "../variables.scss";
.toast-container {
z-index: 11;
border-radius: $toast-border-radius;
div > .toast-body {
color: $dark-lighter;
}
}
.success-bg {
background-color: var(--success);
}
.danger-bg {
background-color: var(--danger);
}
</style>

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
:root {
--success: #{$success};
--warning: #{$warning};
--danger: #{$danger};
--danger-dark-transparent: #{$danger-dark-transparent};
--info: #{$info};
--info-dark-transparent: #{$info-dark-transparent};
}
html[data-theme="light"] {
--color-bg: #{$light};
--color-text: #{$dark};
--bg-lighter: #{$light-darker};
--bg-modal: #{$light};
--svg-close: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
}
html[data-theme="dark"] {
--color-bg: #{$dark};
--color-text: #{$light};
--bg-lighter: #{$dark-lighter};
--bg-modal: #{$dark-lighter};
--svg-close: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
}

View File

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

View 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(&params); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": "paramsparser: " + err.Error(),
})
}
if err := queries.PatchDevice(&params, id); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": true,
"msg": "patching: " + err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"error": false,
"msg": "patched",
})
}
func DeleteDevice(c *fiber.Ctx) error {
var device models.Device
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
if err := queries.GetOneDevice(&device, id); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
if err := queries.DeleteDevice(&device, id); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"error": false,
"msg": "deleted",
})
}

View File

@@ -0,0 +1,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
View 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
View 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
View 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"))
}

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

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

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

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

View File

@@ -1,12 +1,22 @@
version: "3" version: "3"
services: services:
app: upsnap_frontend:
container_name: upsnap_app container_name: upsnap_frontend
image: seriousm4x/upsnap:latest 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 network_mode: host
restart: unless-stopped restart: unless-stopped
environment: environment:
- FRONTEND_PORT=8000
- BACKEND_PORT=8001 - BACKEND_PORT=8001
- BACKEND_IS_PROXIED=false # set this to true, if you use a reverse proxy - BACKEND_IS_PROXIED=false # set this to true, if you use a reverse proxy
- DB_TYPE=sqlite # required - DB_TYPE=sqlite # required
@@ -20,7 +30,6 @@ services:
#- DJANGO_LANGUAGE_CODE=de # optional (default: en) #- DJANGO_LANGUAGE_CODE=de # optional (default: en)
#- DJANGO_TIME_ZONE=Europe/Berlin # optional (default: UTC) #- 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) #- 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: volumes:
- ./db/:/app/backend/db/ - ./db/:/app/backend/db/
depends_on: depends_on:

13
frontend/.eslintignore Normal file
View File

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

20
frontend/.eslintrc.cjs Normal file
View 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
View 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
View File

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

13
frontend/.prettierignore Normal file
View File

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

9
frontend/.prettierrc Normal file
View File

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

14
frontend/Dockerfile Normal file
View 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
View File

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

5210
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

9
frontend/src/app.d.ts vendored Normal file
View 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
View 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>

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

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

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

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

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

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

View File

@@ -26,4 +26,4 @@ $toast-box-shadow: none;
$toast-border-width: 0px; $toast-border-width: 0px;
// font awesome // font awesome
$fa-font-path: "webfonts"; $fa-font-path: 'webfonts';

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

20
frontend/svelte.config.js Normal file
View 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
View File

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

8
frontend/vite.config.js Normal file
View File

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