add shutdown via sshpass or net rpc, also improve wake and other things

This commit is contained in:
Maxi Quoß
2022-03-11 01:27:47 +01:00
parent bcba3f4bf2
commit 396df428c2
12 changed files with 288 additions and 156 deletions

View File

@@ -24,7 +24,7 @@ WORKDIR /app
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update &&\ RUN apt-get update &&\
apt-get install -y --no-install-recommends default-mysql-client nodejs npm iputils-ping nmap &&\ apt-get install -y --no-install-recommends default-mysql-client nodejs npm iputils-ping nmap samba-common sshpass &&\
apt-get clean &&\ apt-get clean &&\
rm -rf /var/lib/{apt,dpkg,cache,log}/ rm -rf /var/lib/{apt,dpkg,cache,log}/
COPY --from=python-build /opt/venv /opt/venv COPY --from=python-build /opt/venv /opt/venv

View File

@@ -45,6 +45,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',

View File

@@ -17,7 +17,7 @@ if os.getenv("DJANGO_SUPERUSER_USER") and os.getenv("DJANGO_SUPERUSER_PASSWORD")
[i.delete() for i in Websocket.objects.all()] [i.delete() for i in Websocket.objects.all()]
Websocket.objects.create(visitors=0) Websocket.objects.create(visitors=0)
# notifications # ping interval
if os.environ.get("PING_INTERVAL"): if os.environ.get("PING_INTERVAL"):
ping_interval = os.environ.get("PING_INTERVAL") ping_interval = os.environ.get("PING_INTERVAL")
else: else:

View File

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

View File

@@ -4,13 +4,11 @@ import subprocess
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from django.utils.dateparse import parse_datetime from django_celery_beat.models import (CrontabSchedule, IntervalSchedule,
from django.utils.timezone import make_aware PeriodicTask)
from django_celery_beat.models import (ClockedSchedule, CrontabSchedule,
IntervalSchedule, PeriodicTask)
from wol.commands import shutdown, wake
from wol.models import Device, Port, Settings, Websocket from wol.models import Device, Port, Settings, Websocket
from wol.wake import wake
class WSConsumer(AsyncWebsocketConsumer): class WSConsumer(AsyncWebsocketConsumer):
@@ -56,7 +54,18 @@ class WSConsumer(AsyncWebsocketConsumer):
"wol", { "wol", {
"type": "send_group", "type": "send_group",
"message": { "message": {
"type": "wake", "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"] "message": received["id"]
} }
} }
@@ -78,8 +87,6 @@ class WSConsumer(AsyncWebsocketConsumer):
await self.update_port(received["data"]) await self.update_port(received["data"])
elif received["type"] == "update_settings": elif received["type"] == "update_settings":
await self.update_settings(received["data"]) await self.update_settings(received["data"])
elif received["type"] == "celery":
await self.celery_create_scheduled_wake(received["data"])
elif received["type"] == "scan_network": elif received["type"] == "scan_network":
await self.send(text_data=json.dumps({ await self.send(text_data=json.dumps({
"type": "scan_network", "type": "scan_network",
@@ -115,6 +122,11 @@ class WSConsumer(AsyncWebsocketConsumer):
dev = Device.objects.filter(id=id).first() dev = Device.objects.filter(id=id).first()
wake(dev.mac, dev.ip, dev.netmask) 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 @database_sync_to_async
def get_all_devices(self): def get_all_devices(self):
devices = Device.objects.all() devices = Device.objects.all()
@@ -127,9 +139,14 @@ class WSConsumer(AsyncWebsocketConsumer):
"mac": dev.mac, "mac": dev.mac,
"netmask": dev.netmask, "netmask": dev.netmask,
"ports": [], "ports": [],
"cron": { "wake": {
"enabled": False, "enabled": False,
"value": "" "cron": ""
},
"shutdown": {
"enabled": False,
"cron": "",
"command": dev.shutdown_cmd
} }
} }
for p in Port.objects.all().order_by("number"): for p in Port.objects.all().order_by("number"):
@@ -139,16 +156,18 @@ class WSConsumer(AsyncWebsocketConsumer):
"checked": False, "checked": False,
"open": False "open": False
}) })
try: for action in ["wake", "shutdown"]:
task = PeriodicTask.objects.filter( try:
name=data["name"], task="wol.tasks.scheduled_wake", crontab_id__isnull=False).get() task = PeriodicTask.objects.filter(
if task: name=f"{data['name']}-{action}",
cron = CrontabSchedule.objects.get(id=task.crontab_id) task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
data["cron"]["enabled"] = task.enabled if task:
data["cron"]["value"] = " ".join( wake = CrontabSchedule.objects.get(id=task.crontab_id)
[cron.minute, cron.hour, cron.day_of_week, cron.day_of_month, cron.month_of_year]) data[action]["enabled"] = task.enabled
except PeriodicTask.DoesNotExist: data[action]["cron"] = " ".join(
pass [wake.minute, wake.hour, wake.day_of_week, wake.day_of_month, wake.month_of_year])
except PeriodicTask.DoesNotExist:
pass
d.append(data) d.append(data)
return d return d
@@ -164,7 +183,8 @@ class WSConsumer(AsyncWebsocketConsumer):
defaults={ defaults={
"name": data["name"], "name": data["name"],
"ip": data["ip"], "ip": data["ip"],
"netmask": data["netmask"] "netmask": data["netmask"],
"shutdown_cmd": data["shutdown"]["command"]
} }
) )
if data.get("ports"): if data.get("ports"):
@@ -174,36 +194,37 @@ class WSConsumer(AsyncWebsocketConsumer):
number=port["number"], name=port["name"]) number=port["number"], name=port["name"])
obj.port.add(p) obj.port.add(p)
else: else:
p = Port.objects.filter(number=port["number"]) p = Port.objects.filter(number=port["number"]).first()
if p.exists(): if p and p in obj.port.all():
obj.port.remove(p) obj.port.remove(p)
if data.get("cron"): for action in ["wake", "shutdown"]:
if data["cron"]["enabled"]: if data.get(action):
cron_value = data["cron"]["value"].strip().split(" ") if data[action]["enabled"]:
if not len(cron_value) == 5: cron = data[action]["cron"].strip().split(" ")
return if not len(cron) == 5:
minute, hour, dom, month, dow = cron_value return
schedule, _ = CrontabSchedule.objects.get_or_create( minute, hour, dom, month, dow = cron
minute=minute, schedule, _ = CrontabSchedule.objects.get_or_create(
hour=hour, minute=minute,
day_of_week=dow, hour=hour,
day_of_month=dom, day_of_week=dow,
month_of_year=month day_of_month=dom,
) month_of_year=month
PeriodicTask.objects.update_or_create( )
name=data["name"], PeriodicTask.objects.update_or_create(
defaults={ name=f"{data['name']}-{action}",
"crontab": schedule, defaults={
"task": "wol.tasks.scheduled_wake", "crontab": schedule,
"args": json.dumps([data["id"]]), "task": f"wol.tasks.scheduled_{action}",
"enabled": True "args": json.dumps([data["id"]]),
} "enabled": True
) }
else: )
for task in PeriodicTask.objects.filter(name=data["name"], task="wol.tasks.scheduled_wake"): else:
task.enabled = False for task in PeriodicTask.objects.filter(name=f"{data['name']}-{action}", task=f"wol.tasks.scheduled_{action}"):
task.save() task.enabled = False
task.save()
@database_sync_to_async @database_sync_to_async
def update_port(self, data): def update_port(self, data):
@@ -223,7 +244,8 @@ class WSConsumer(AsyncWebsocketConsumer):
data = { data = {
"discovery": conf.scan_address, "discovery": conf.scan_address,
"interval": conf.interval, "interval": conf.interval,
"scan_network": [] "scan_network": [],
"notifications": conf.notifications
} }
return data return data
@@ -235,7 +257,8 @@ class WSConsumer(AsyncWebsocketConsumer):
id=1, id=1,
defaults={ defaults={
"scan_address": data["discovery"], "scan_address": data["discovery"],
"interval": data["interval"] "interval": data["interval"],
"notifications": data["notifications"]
} }
) )
schedule, _ = IntervalSchedule.objects.get_or_create( schedule, _ = IntervalSchedule.objects.get_or_create(
@@ -249,19 +272,6 @@ class WSConsumer(AsyncWebsocketConsumer):
} }
) )
@database_sync_to_async
def celery_create_scheduled_wake(self, data):
aware_time = make_aware(parse_datetime(data["time"]))
schedule, _ = ClockedSchedule.objects.get_or_create(
clocked_time=aware_time
)
PeriodicTask.objects.get_or_create(
clocked=schedule,
name=data["name"],
task="wol.tasks.scheduled_wake",
args=json.dumps([data["id"]])
)
@database_sync_to_async @database_sync_to_async
def scan_network(self): def scan_network(self):
data = [] data = []

View File

@@ -13,8 +13,8 @@ class Device(models.Model):
ip = models.GenericIPAddressField() ip = models.GenericIPAddressField()
mac = models.CharField(max_length=17) mac = models.CharField(max_length=17)
netmask = models.CharField(max_length=15, default="255.255.255.0", blank=False, null=False) netmask = models.CharField(max_length=15, default="255.255.255.0", blank=False, null=False)
scheduled_wake = models.DateTimeField(blank=True, null=True)
port = models.ManyToManyField(Port, blank=True) port = models.ManyToManyField(Port, blank=True)
shutdown_cmd = models.TextField(null=True, blank=True)
class Websocket(models.Model): class Websocket(models.Model):
visitors = models.PositiveSmallIntegerField(blank=False, null=False, default=0) visitors = models.PositiveSmallIntegerField(blank=False, null=False, default=0)
@@ -23,3 +23,4 @@ class Settings(models.Model):
sort_by = models.SlugField(default="name") sort_by = models.SlugField(default="name")
scan_address = models.GenericIPAddressField(null=True, blank=True) scan_address = models.GenericIPAddressField(null=True, blank=True)
interval = models.PositiveSmallIntegerField(null=True, blank=True) interval = models.PositiveSmallIntegerField(null=True, blank=True)
notifications = models.BooleanField(default=True)

View File

@@ -5,10 +5,10 @@ import threading
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from celery import shared_task from celery import shared_task
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django_celery_beat.models import PeriodicTask, CrontabSchedule from django_celery_beat.models import CrontabSchedule, PeriodicTask
from wol.commands import shutdown, wake
from wol.models import Device, Port, Websocket from wol.models import Device, Port, Websocket
from wol.wake import wake
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
@@ -37,9 +37,14 @@ class WolDevice:
"netmask": dev.netmask, "netmask": dev.netmask,
"up": False, "up": False,
"ports": [], "ports": [],
"cron": { "wake": {
"enabled": False, "enabled": False,
"value": "" "cron": ""
},
"shutdown": {
"enabled": False,
"cron": "",
"command": dev.shutdown_cmd
} }
} }
@@ -56,7 +61,8 @@ class WolDevice:
if self.ping_device(dev.ip): if self.ping_device(dev.ip):
data["up"] = True data["up"] = True
for port in dev.port.all(): for port in dev.port.all():
index = next(i for i, d in enumerate(data["ports"]) if d["number"] == port.number) index = next(i for i, d in enumerate(
data["ports"]) if d["number"] == port.number)
if self.check_port(dev.ip, port.number): if self.check_port(dev.ip, port.number):
data["ports"][index]["checked"] = True data["ports"][index]["checked"] = True
data["ports"][index]["open"] = True data["ports"][index]["open"] = True
@@ -64,15 +70,19 @@ class WolDevice:
data["ports"][index]["checked"] = True data["ports"][index]["checked"] = True
data["ports"][index]["open"] = False data["ports"][index]["open"] = False
# set cron for scheduled wake # set cron for wake and shutdown
try: for action in ["wake", "shutdown"]:
task = PeriodicTask.objects.filter(name=data["name"], task="wol.tasks.scheduled_wake", crontab_id__isnull=False).get() try:
if task: task = PeriodicTask.objects.filter(
cron = CrontabSchedule.objects.get(id=task.crontab_id) name=f"{data['name']}-{action}",
data["cron"]["enabled"] = task.enabled task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get()
data["cron"]["value"] = " ".join([cron.minute, cron.hour, cron.day_of_week, cron.day_of_month, cron.month_of_year]) if task:
except PeriodicTask.DoesNotExist: wake = CrontabSchedule.objects.get(id=task.crontab_id)
pass 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)( async_to_sync(channel_layer.group_send)(
"wol", {"type": "send_group", "message": { "wol", {"type": "send_group", "message": {
@@ -103,4 +113,32 @@ def scheduled_wake(id):
task.delete() task.delete()
return return
wake(device.mac, device.ip, device.netmask) 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

@@ -19,11 +19,8 @@
store.subscribe(currentMessage => { store.subscribe(currentMessage => {
if (currentMessage.type == "init") { if (currentMessage.type == "init") {
// create devices // create devices
for (let index = 0; index < currentMessage.message.devices.length; index++) { devices = [...currentMessage.message.devices]
const element = currentMessage.message.devices[index]; devices = devices;
devices.push(element);
devices = devices;
}
devices.sort(compare); devices.sort(compare);
settings = currentMessage.message.settings; settings = currentMessage.message.settings;
} else if (currentMessage.type == "status") { } else if (currentMessage.type == "status") {
@@ -36,16 +33,15 @@
devices[index] = currentMessage.message; devices[index] = currentMessage.message;
} }
devices.sort(compare) devices.sort(compare)
// set device status // set device status
if (currentMessage.message.up == true) { if (currentMessage.message.up == true) {
setUp(currentMessage.message); setUp(currentMessage.message);
} else { } else {
setDown(currentMessage.message); setDown(currentMessage.message);
} }
} else if (currentMessage.type == "wake") { } else if (currentMessage.type == "pending") {
// set device waking // set device pending
setWake(currentMessage.message) setPending(currentMessage.message)
} else if (currentMessage.type == "visitor") { } else if (currentMessage.type == "visitor") {
// update visitor count // update visitor count
visitors = currentMessage.message; visitors = currentMessage.message;
@@ -120,7 +116,7 @@
} }
} }
function setWake(id) { function setPending(id) {
const dot = document.querySelector(`#dot-${id}`); const dot = document.querySelector(`#dot-${id}`);
const spinner = document.querySelector(`#spinner-${id}`); const spinner = document.querySelector(`#spinner-${id}`);
dot.classList.add("d-none"); dot.classList.add("d-none");
@@ -178,7 +174,7 @@
} }
.modal-content { .modal-content {
background-color: var(--bg-lighter); background-color: var(--bg-modal);
} }
.modal-header { .modal-header {
@@ -224,8 +220,6 @@
.callout { .callout {
padding: 1rem; padding: 1rem;
margin-top: 1.25rem;
margin-bottom: 1.25rem;
border-left-width: 0.25rem; border-left-width: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;

View File

@@ -12,6 +12,13 @@
}) })
} }
function shutdown(id) {
store.sendMessage({
type: "shutdown",
id: id
})
}
function deleteDevice() { function deleteDevice() {
store.sendMessage({ store.sendMessage({
type: "delete_device", type: "delete_device",
@@ -74,17 +81,34 @@
<div class="row"> <div class="row">
<div class="col-auto me-auto"> <div class="col-auto me-auto">
<div id="spinner-{device.id}" class="spinner-border warning d-none" role="status"></div> <div id="spinner-{device.id}" class="spinner-border warning d-none" role="status"></div>
<div class="hover" on:click="{() => wake(device.id)}" role="button"> {#if device.up === true}
{#if device.up === true || device.up === false} {#if device.shutdown.command}
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x {device.up ? 'success' : 'danger'}"></i> <div class="hover" on:click="{() => 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"></i>
</div>
{:else} {:else}
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x text-muted"></i> <i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x success"></i>
{/if} {/if}
{:else if device.up === false}
<div class="hover" on:click="{() => wake(device.id)}" role="button">
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x danger"></i>
</div>
{:else}
<div class="hover" role="button">
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x text-muted"></i>
</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"></i>
</div> </div>
</div> {/if}
<div class="col-auto" class:d-none={!device.cron.enabled} data-bs-toggle="tooltip" title="Crontab: {device.cron.value}"> {#if device.shutdown.enabled}
<i class="fa-solid fa-repeat fa-2x text-muted"></i> <div class="col-auto px-2" data-bs-toggle="tooltip" title="Shutdown cron: {device.wake.cron}">
</div> <i class="fa-solid fa-circle-stop fa-2x text-muted"></i>
</div>
{/if}
<div class="col-auto hover" data-bs-toggle="modal" data-bs-target="#device-modal-{device.id}" role="button" on:click="{() => openModal()}"> <div class="col-auto hover" data-bs-toggle="modal" data-bs-target="#device-modal-{device.id}" role="button" on:click="{() => openModal()}">
<i class="fa-solid fa-ellipsis-vertical fa-2x"></i> <i class="fa-solid fa-ellipsis-vertical fa-2x"></i>
</div> </div>
@@ -112,38 +136,33 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{JSON.stringify(modalDevice)}
<form id="form-{modalDevice.id}" on:submit|preventDefault={updateDevice}> <form id="form-{modalDevice.id}" on:submit|preventDefault={updateDevice}>
<!-- general -->
<h5 class="fw-bold">General</h5> <h5 class="fw-bold">General</h5>
<div class="row"> <div class="row mb-2">
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <label for="inputName{modalDevice.id}" class="form-label">Device name</label>
<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>
<input type="text" class="form-control" id="inputName{modalDevice.id}" bind:value="{modalDevice.name}" required>
</div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <label for="inputMac{modalDevice.id}" class="form-label">Mac address</label>
<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>
<input type="text" class="form-control" id="inputMac{modalDevice.mac}" bind:value="{modalDevice.mac}" required>
</div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <label for="inputIp{modalDevice.id}" class="form-label">IP address</label>
<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}" pattern="^([01]?\d\d?|2[0-4]\d|25[0-5])(?:\.(?:[01]?\d\d?|2[0-4]\d|25[0-5])){'{'}3{'}'}$" required>
<input type="text" class="form-control" id="inputIp{modalDevice.id}" bind:value="{modalDevice.ip}" pattern="^([01]?\d\d?|2[0-4]\d|25[0-5])(?:\.(?:[01]?\d\d?|2[0-4]\d|25[0-5])){'{'}3{'}'}$" required>
</div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <label for="inputNetmask{modalDevice.id}" class="form-label">Netmask</label>
<label for="inputNetmask{modalDevice.id}" class="form-label">Netmask</label> <input type="text" class="form-control" id="inputNetmask{modalDevice.id}" bind:value="{modalDevice.netmask}" pattern="^(((255\.){'{'}3{'}'}(255|254|252|248|240|224|192|128|0+))|((255\.){'{'}2{'}'}(255|254|252|248|240|224|192|128|0+)\.0)|((255\.)(255|254|252|248|240|224|192|128|0+)(\.0+){'{'}2{'}'})|((255|254|252|248|240|224|192|128|0+)(\.0+){'{'}3{'}'}))$" required>
<input type="text" class="form-control" id="inputNetmask{modalDevice.id}" bind:value="{modalDevice.netmask}" pattern="^(((255\.){'{'}3{'}'}(255|254|252|248|240|224|192|128|0+))|((255\.){'{'}2{'}'}(255|254|252|248|240|224|192|128|0+)\.0)|((255\.)(255|254|252|248|240|224|192|128|0+)(\.0+){'{'}2{'}'})|((255|254|252|248|240|224|192|128|0+)(\.0+){'{'}3{'}'}))$" required>
</div>
</div> </div>
</div> </div>
<h5 class="fw-bold">Ports</h5> <!-- ports -->
<p>Select ports to check if they are open.</p> <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} {#if modalDevice.ports.length === 0}
<p class="mb-0">No ports available. Add ports below.</p> <p class="mb-0">No ports available. Add ports below.</p>
{/if} {/if}
@@ -153,41 +172,99 @@
<label class="form-check-label" for="{device.id}-port-{port.number}">{port.name} <span class="text-muted">({port.number})</span></label> <label class="form-check-label" for="{device.id}-port-{port.number}">{port.name} <span class="text-muted">({port.number})</span></label>
</div> </div>
{/each} {/each}
<label class="form-label mt-3" for="{device.id}-custom-port">Custom port</label> <label class="form-label mt-2" for="{device.id}-custom-port">Custom port</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" id="{device.id}-custom-port" class="form-control" placeholder="Name" aria-label="Name" aria-describedby="button-addon2" bind:value={customPort.name}> <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" placeholder="Port" aria-label="Port" aria-describedby="button-addon2" bind:value={customPort.number} on:input={validatePort}> <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> <button class="btn btn-secondary" type="button" id="button-addon2" on:click="{updatePort}">Update Port</button>
</div> </div>
<div class="callout callout-info"> <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">
<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> <i class="fa-solid fa-angle-down me-2"></i>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> </div>
<h5 class="fw-bold">Scheduled wake</h5> <!-- scheduled wake -->
<p>Wake your devices at a given time.</p> <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"> <div class="form-check">
<input class="form-check-input" type="radio" name="flexRadioSchedule" id="flexRadioDisabled{modalDevice.id}" <input class="form-check-input" type="radio" name="wake-disable" id="wake-radio-disabled-{modalDevice.id}"
bind:group={modalDevice["cron"]["enabled"]} value={false} checked={!modalDevice["cron"]["enabled"]}> bind:group={modalDevice["wake"]["enabled"]} value={false} checked={!modalDevice["wake"]["enabled"]}>
<label class="form-check-label" for="flexRadioDisabled{modalDevice.id}"> <label class="form-check-label" for="wake-radio-disabled-{modalDevice.id}">
Disabled Disabled
</label> </label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="flexRadioSchedule" id="flexRadioCron{modalDevice.id}" <input class="form-check-input" type="radio" name="wake-cron" id="wake-radio-cron-{modalDevice.id}"
bind:group={modalDevice["cron"]["enabled"]} value={true} checked={modalDevice["cron"]["enabled"]}> bind:group={modalDevice["wake"]["enabled"]} value={true} checked={modalDevice["wake"]["enabled"]}>
<label class="form-check-label" for="flexRadioCron{modalDevice.id}"> <label class="form-check-label" for="wake-radio-cron-{modalDevice.id}">
Cron Enabled
</label> </label>
</div> </div>
<input id="inputCron{modalDevice.id}" type="text" class="form-control" placeholder="* /4 * * *" aria-label="Crontab" aria-describedby="addon-wrapping" bind:value={modalDevice["cron"]["value"]} hidden={!modalDevice["cron"]["enabled"]}> <div class="input-group my-1" hidden={!modalDevice["wake"]["enabled"]}>
<div class="callout callout-info"> <span class="input-group-text rounded-0 rounded-start" id="wake-cron-{modalDevice.id}">Cron</span>
<p class="my-2">The cron field uses common cron syntax. Valid examples:</p> <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"]}>
<pre>Minute Hour DayOfMonth Month DayOfWeek </div>
* /4 * * * (Wake every 4 hours) <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">
0 9 * * 1-5 (Wake from Mo-Fr at 9 a.m.) <i class="fa-solid fa-angle-down me-2"></i>How to use
</pre> </button>
<p class="mb-0">Read more about <a href="https://linux.die.net/man/5/crontab" target="_blank">valid syntax here</a> or <a href="https://crontab.guru/" target="_blank">generate</a> it. Expressions starting with "@..." are not supported.</p> <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">valid syntax here</a> or <a href="https://crontab.guru/" target="_blank">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 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"></i>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 user@hostname 'sudo shutdown'
# wake windows hosts
net rpc shutdown --ipaddress 192.168.0.1 --user user%password
</pre>
<p class="mb-0">Read more about <a href="https://linux.die.net/man/1/sshpass" target="_blank">sshpass</a> or <a href="https://linux.die.net/man/8/net" target="_blank">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> </div>
<!-- TODO: add shutdown command -->
</form> </form>
</div> </div>
<div class="modal-footer justify-content-between"> <div class="modal-footer justify-content-between">

View File

@@ -5,9 +5,14 @@
export let settings; export let settings;
let addDevice = { let addDevice = {
cron: { wake: {
enabled: false, enabled: false,
value: "" cron: ""
},
shutdown: {
enabled: false,
cron: "",
command: ""
} }
} }
@@ -125,13 +130,13 @@
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
<label for="inputNameAddDevice" class="form-label">Device name</label> <label for="inputNameAddDevice" class="form-label">Device name</label>
<input type="text" class="form-control" id="inputNameAddDevice" bind:value="{addDevice.name}" required> <input type="text" class="form-control" id="inputNameAddDevice" placeholder="Max PC" bind:value="{addDevice.name}" required>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
<label for="inputMacAddDevice" class="form-label">Mac address</label> <label for="inputMacAddDevice" class="form-label">Mac address</label>
<input type="text" class="form-control" id="inputMacAddDevice" bind:value="{addDevice.mac}" pattern="^([0-9A-Fa-f]{'{'}2{'}'}[:-]){'{'}5{'}'}([0-9A-Fa-f]{'{'}2{'}'})|([0-9a-fA-F]{'{'}4{'}'}\\.[0-9a-fA-F]{'{'}4{'}'}\\.[0-9a-fA-F]{'{'}4{'}'})$" required> <input type="text" class="form-control" id="inputMacAddDevice" placeholder="aa:aa:aa:aa:aa:aa" bind:value="{addDevice.mac}" pattern="^([0-9A-Fa-f]{'{'}2{'}'}[:-]){'{'}5{'}'}([0-9A-Fa-f]{'{'}2{'}'})|([0-9a-fA-F]{'{'}4{'}'}\\.[0-9a-fA-F]{'{'}4{'}'}\\.[0-9a-fA-F]{'{'}4{'}'})$" required>
</div> </div>
</div> </div>
</div> </div>
@@ -139,13 +144,13 @@
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
<label for="inputIpAddDevice" class="form-label">IP address</label> <label for="inputIpAddDevice" class="form-label">IP address</label>
<input type="text" class="form-control" id="inputIpAddDevice" bind:value="{addDevice.ip}" pattern="^([01]?\d\d?|2[0-4]\d|25[0-5])(?:\.(?:[01]?\d\d?|2[0-4]\d|25[0-5])){'{'}3{'}'}$" required> <input type="text" class="form-control" id="inputIpAddDevice" placeholder="192.168.1.1" bind:value="{addDevice.ip}" pattern="^([01]?\d\d?|2[0-4]\d|25[0-5])(?:\.(?:[01]?\d\d?|2[0-4]\d|25[0-5])){'{'}3{'}'}$" required>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
<label for="inputNetmaskAddDevice" class="form-label">Netmask</label> <label for="inputNetmaskAddDevice" class="form-label">Netmask</label>
<input type="text" class="form-control" id="inputNetmaskAddDevice" bind:value="{addDevice.netmask}" pattern="^(((255\.){'{'}3{'}'}(255|254|252|248|240|224|192|128|0+))|((255\.){'{'}2{'}'}(255|254|252|248|240|224|192|128|0+)\.0)|((255\.)(255|254|252|248|240|224|192|128|0+)(\.0+){'{'}2{'}'})|((255|254|252|248|240|224|192|128|0+)(\.0+){'{'}3{'}'}))$" required> <input type="text" class="form-control" id="inputNetmaskAddDevice" placeholder="255.255.255.0" bind:value="{addDevice.netmask}" pattern="^(((255\.){'{'}3{'}'}(255|254|252|248|240|224|192|128|0+))|((255\.){'{'}2{'}'}(255|254|252|248|240|224|192|128|0+)\.0)|((255\.)(255|254|252|248|240|224|192|128|0+)(\.0+){'{'}2{'}'})|((255|254|252|248|240|224|192|128|0+)(\.0+){'{'}3{'}'}))$" required>
</div> </div>
</div> </div>
</div> </div>
@@ -156,8 +161,8 @@
</div> </div>
<h5 class="fw-bold">Network discovery</h5> <h5 class="fw-bold">Network discovery</h5>
{#if !settings.discovery} {#if !settings.discovery}
<div class="callout callout-danger"> <div class="callout callout-danger mb-2">
<p class="my-0">To enable this option, please enter your network address in the settings.</p> <p class="m-0">To enable this option, please enter your network address in the settings.</p>
</div> </div>
{/if} {/if}
<button id="btnScan" class="btn btn-secondary" type="button" on:click={scanNetwork} disabled={!settings.discovery}> <button id="btnScan" class="btn btn-secondary" type="button" on:click={scanNetwork} disabled={!settings.discovery}>
@@ -239,8 +244,8 @@
</div> </div>
</div> </div>
<h5 class="fw-bold">Backup/Restore</h5> <h5 class="fw-bold">Backup/Restore</h5>
<div class="callout callout-info"> <div class="callout callout-info mb-2">
<p class="mb-0">Backup file structure has changed in v2. You can still restore both versions with this file upload.</p> <p class="m-0">Backup file structure has changed in v2. You can still restore both versions with this file upload.</p>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="inputRestore" class="form-label">Restore from .json</label> <label for="inputRestore" class="form-label">Restore from .json</label>

View File

@@ -8,7 +8,7 @@
<strong id="toast-title" class="me-auto">{toast.title}</strong> <strong id="toast-title" class="me-auto">{toast.title}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div> </div>
<div class="toast-body"> <div class="toast-body fw-bold">
{toast.message} {toast.message}
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ html[data-theme="light"] {
--color-bg: #{$light}; --color-bg: #{$light};
--color-text: #{$dark}; --color-text: #{$dark};
--bg-lighter: #{$light-darker}; --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; --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;
} }
@@ -18,5 +19,6 @@ html[data-theme="dark"] {
--color-bg: #{$dark}; --color-bg: #{$dark};
--color-text: #{$light}; --color-text: #{$light};
--bg-lighter: #{$dark-lighter}; --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; --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;
} }