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 DEBIAN_FRONTEND=noninteractive
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 &&\
rm -rf /var/lib/{apt,dpkg,cache,log}/
COPY --from=python-build /opt/venv /opt/venv

View File

@@ -45,6 +45,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'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()]
Websocket.objects.create(visitors=0)
# notifications
# ping interval
if os.environ.get("PING_INTERVAL"):
ping_interval = os.environ.get("PING_INTERVAL")
else:

View File

@@ -1,8 +1,12 @@
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

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

View File

@@ -13,8 +13,8 @@ class Device(models.Model):
ip = models.GenericIPAddressField()
mac = models.CharField(max_length=17)
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)
shutdown_cmd = models.TextField(null=True, blank=True)
class Websocket(models.Model):
visitors = models.PositiveSmallIntegerField(blank=False, null=False, default=0)
@@ -23,3 +23,4 @@ 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

@@ -5,10 +5,10 @@ import threading
from asgiref.sync import async_to_sync
from celery import shared_task
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.wake import wake
channel_layer = get_channel_layer()
@@ -37,9 +37,14 @@ class WolDevice:
"netmask": dev.netmask,
"up": False,
"ports": [],
"cron": {
"wake": {
"enabled": False,
"value": ""
"cron": ""
},
"shutdown": {
"enabled": False,
"cron": "",
"command": dev.shutdown_cmd
}
}
@@ -56,7 +61,8 @@ class WolDevice:
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)
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
@@ -64,15 +70,19 @@ class WolDevice:
data["ports"][index]["checked"] = True
data["ports"][index]["open"] = False
# set cron for scheduled wake
try:
task = PeriodicTask.objects.filter(name=data["name"], task="wol.tasks.scheduled_wake", crontab_id__isnull=False).get()
if task:
cron = CrontabSchedule.objects.get(id=task.crontab_id)
data["cron"]["enabled"] = task.enabled
data["cron"]["value"] = " ".join([cron.minute, cron.hour, cron.day_of_week, cron.day_of_month, cron.month_of_year])
except PeriodicTask.DoesNotExist:
pass
# 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": {
@@ -103,4 +113,32 @@ def scheduled_wake(id):
task.delete()
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 => {
if (currentMessage.type == "init") {
// create devices
for (let index = 0; index < currentMessage.message.devices.length; index++) {
const element = currentMessage.message.devices[index];
devices.push(element);
devices = devices;
}
devices = [...currentMessage.message.devices]
devices = devices;
devices.sort(compare);
settings = currentMessage.message.settings;
} else if (currentMessage.type == "status") {
@@ -36,16 +33,15 @@
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 == "wake") {
// set device waking
setWake(currentMessage.message)
} else if (currentMessage.type == "pending") {
// set device pending
setPending(currentMessage.message)
} else if (currentMessage.type == "visitor") {
// update visitor count
visitors = currentMessage.message;
@@ -120,7 +116,7 @@
}
}
function setWake(id) {
function setPending(id) {
const dot = document.querySelector(`#dot-${id}`);
const spinner = document.querySelector(`#spinner-${id}`);
dot.classList.add("d-none");
@@ -178,7 +174,7 @@
}
.modal-content {
background-color: var(--bg-lighter);
background-color: var(--bg-modal);
}
.modal-header {
@@ -224,8 +220,6 @@
.callout {
padding: 1rem;
margin-top: 1.25rem;
margin-bottom: 1.25rem;
border-left-width: 0.25rem;
border-radius: 0.25rem;

View File

@@ -12,6 +12,13 @@
})
}
function shutdown(id) {
store.sendMessage({
type: "shutdown",
id: id
})
}
function deleteDevice() {
store.sendMessage({
type: "delete_device",
@@ -74,17 +81,34 @@
<div class="row">
<div class="col-auto me-auto">
<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 || device.up === false}
<i id="dot-{device.id}" class="fa-solid fa-power-off fa-2x {device.up ? 'success' : 'danger'}"></i>
{#if device.up === true}
{#if device.shutdown.command}
<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}
<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}
{: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 class="col-auto" class:d-none={!device.cron.enabled} data-bs-toggle="tooltip" title="Crontab: {device.cron.value}">
<i class="fa-solid fa-repeat fa-2x text-muted"></i>
</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"></i>
</div>
{/if}
<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>
</div>
@@ -112,38 +136,33 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{JSON.stringify(modalDevice)}
<form id="form-{modalDevice.id}" on:submit|preventDefault={updateDevice}>
<!-- general -->
<h5 class="fw-bold">General</h5>
<div class="row">
<div class="row mb-2">
<div class="col-sm">
<div class="mb-3">
<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>
<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">
<div class="mb-3">
<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>
<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">
<div class="col-sm">
<div class="mb-3">
<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>
</div>
<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>
</div>
<div class="col-sm">
<div class="mb-3">
<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>
</div>
<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>
</div>
</div>
<h5 class="fw-bold">Ports</h5>
<p>Select ports to check if they are open.</p>
<!-- 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}
@@ -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>
</div>
{/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">
<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="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="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>
<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>
<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"></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>
<h5 class="fw-bold">Scheduled wake</h5>
<p>Wake your devices at a given time.</p>
<!-- 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="flexRadioSchedule" id="flexRadioDisabled{modalDevice.id}"
bind:group={modalDevice["cron"]["enabled"]} value={false} checked={!modalDevice["cron"]["enabled"]}>
<label class="form-check-label" for="flexRadioDisabled{modalDevice.id}">
<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="flexRadioSchedule" id="flexRadioCron{modalDevice.id}"
bind:group={modalDevice["cron"]["enabled"]} value={true} checked={modalDevice["cron"]["enabled"]}>
<label class="form-check-label" for="flexRadioCron{modalDevice.id}">
Cron
<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>
<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="callout callout-info">
<p class="my-2">The cron field uses common cron syntax. Valid examples:</p>
<pre>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">generate</a> it. Expressions starting with "@..." are not supported.</p>
<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"></i>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">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>
<!-- TODO: add shutdown command -->
</form>
</div>
<div class="modal-footer justify-content-between">

View File

@@ -5,9 +5,14 @@
export let settings;
let addDevice = {
cron: {
wake: {
enabled: false,
value: ""
cron: ""
},
shutdown: {
enabled: false,
cron: "",
command: ""
}
}
@@ -125,13 +130,13 @@
<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" bind:value="{addDevice.name}" required>
<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" 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>
@@ -139,13 +144,13 @@
<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" 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 class="col-sm">
<div class="mb-3">
<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>
@@ -156,8 +161,8 @@
</div>
<h5 class="fw-bold">Network discovery</h5>
{#if !settings.discovery}
<div class="callout callout-danger">
<p class="my-0">To enable this option, please enter your network address in the settings.</p>
<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}>
@@ -239,8 +244,8 @@
</div>
</div>
<h5 class="fw-bold">Backup/Restore</h5>
<div class="callout callout-info">
<p class="mb-0">Backup file structure has changed in v2. You can still restore both versions with this file upload.</p>
<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>

View File

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

View File

@@ -11,6 +11,7 @@ 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;
}
@@ -18,5 +19,6 @@ 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;
}