Files
UpSnap/frontend/src/lib/components/DeviceForm.svelte
2025-03-16 14:42:55 +01:00

726 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import DeviceFormPort from '$lib/components/DeviceFormPort.svelte';
import { nextCronDate, parseCron as validateCron } from '$lib/helpers/cron';
import { m } from '$lib/paraglide/messages';
import { permission, pocketbase } from '$lib/stores/pocketbase';
import type { Device, Group, Port } from '$lib/types/device';
import { faSave, faTrash, faX } from '@fortawesome/free-solid-svg-icons';
import { onMount } from 'svelte';
import Fa from 'svelte-fa';
import toast from 'svelte-french-toast';
export let device: Device;
let deleteModal: HTMLDialogElement;
let deviceGroups = [] as Group[];
let newGroup = '';
onMount(async () => {
await getGroups();
});
async function save() {
// validate crons
if (device.wake_cron_enabled && !(await validateCron(device.wake_cron))) {
toast.error(m.settings_invalid_cron());
throw new Error('wake_cron not valid');
}
if (device.shutdown_cron_enabled && !(await validateCron(device.shutdown_cron))) {
toast.error(m.settings_invalid_cron());
throw new Error('shutdown_cron not valid');
}
// create/update all ports
let portIds: string[] = [];
await Promise.all(
device.expand.ports.map(async (port: Port) => {
if (port.id === undefined) {
const data = await createPort(port as Port);
if (data === undefined) return;
portIds = [...portIds, data.id];
} else {
const data = await updatePort(port as Port);
if (data === undefined) return;
portIds = [...portIds, data.id];
}
})
);
device.ip = device.ip.replaceAll(' ', '');
device.mac = device.mac.replaceAll(' ', '');
device.netmask = device.netmask.replaceAll(' ', '');
device.wake_cmd = device.wake_cmd.trim();
device.shutdown_cmd = device.shutdown_cmd.trim();
device.wake_cron = device.wake_cron.trim();
device.shutdown_cron = device.shutdown_cron.trim();
device.ports = portIds;
if (device.id) {
updateDevice(device);
} else {
createDevice(device);
}
}
async function updateDevice(device: Device) {
$pocketbase
.collection('devices')
.update(device.id, device)
.then(() => {
toast.success(m.toasts_device_updated({ device: device.name }));
goto('/');
})
.catch((err) => {
toast.error(err.message);
});
}
async function createDevice(device: Device) {
device.created_by = $pocketbase.authStore.isSuperuser
? ''
: ($pocketbase.authStore.record?.id ?? '');
$pocketbase
.collection('devices')
.create(device)
.then(() => {
toast.success(m.toasts_device_created({ device: device.name }));
goto('/');
})
.catch((err) => {
toast.error(err.message);
});
}
async function createPort(port: Port): Promise<Port | undefined> {
let newport: Port | undefined;
await $pocketbase
.collection('ports')
.create(port)
.then((data) => {
newport = data as Port;
})
.catch((err) => {
toast.error(err.message);
newport = undefined;
});
return newport;
}
async function updatePort(port: Port): Promise<Port | undefined> {
let newport: Port | undefined;
await $pocketbase
.collection('ports')
.update(port.id, port)
.then((data) => {
newport = data as Port;
})
.catch((err) => {
toast.error(err.message);
newport = undefined;
});
return newport;
}
function deleteDevice() {
$pocketbase
.collection('devices')
.delete(device.id)
.then(() => {
toast.success(m.toasts_device_deleted({ device: device.name }));
goto('/');
})
.catch((err) => {
toast.error(err.message);
});
}
function createEmptyPort() {
if (device === undefined) return;
device.expand.ports = [...device.expand.ports, { name: '', number: 1 } as Port];
}
async function getGroups() {
$pocketbase
.collection('device_groups')
.getFullList()
.then((res) => {
deviceGroups = res as Group[];
});
}
function addGroup() {
if (!newGroup) return;
$pocketbase
.collection('device_groups')
.create({
name: newGroup
})
.then((res) => {
deviceGroups = [...deviceGroups, res as Group];
toast.success(m.toasts_group_created({ group: newGroup }));
newGroup = '';
})
.catch((err) => {
toast.error(err.message);
});
}
function deleteGroup(group: Group) {
const i = device.groups.indexOf(group.id);
if (i !== -1) {
device.groups.splice(i, 1);
device.groups = device.groups;
}
$pocketbase
.collection('device_groups')
.delete(group.id)
.then(async () => {
await getGroups();
toast.success(m.toasts_group_deleted({ group: group.name }));
})
.catch((err) => {
toast.error(err.message);
});
}
function toggleGroup(id: string) {
const i = device.groups.indexOf(id);
if (i !== -1) {
device.groups.splice(i, 1);
device.groups = device.groups;
} else {
device.groups = [...device.groups, id];
}
}
</script>
<form on:submit|preventDefault={save}>
<div class="card bg-base-200 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_general()}</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_general_name()} <span class="text-error">*</span></span>
<input
type="text"
placeholder={m.device_general_name()}
class="input"
bind:value={device.name}
required
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_general_ip()} <span class="text-error">*</span></span>
<input
type="text"
placeholder={m.device_general_ip()}
class="input"
bind:value={device.ip}
required
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_general_mac()} <span class="text-error">*</span></span>
<input
type="text"
placeholder={m.device_general_mac()}
class="input"
bind:value={device.mac}
required
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_general_netmask()} <span class="text-error">*</span></span>
<input
type="text"
placeholder={m.device_general_netmask()}
class="input"
bind:value={device.netmask}
required
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_general_description()}</span>
<input
type="text"
placeholder={m.device_general_description()}
class="input"
bind:value={device.description}
/>
</label>
</fieldset>
<span class="badge text-error self-center">* {m.device_general_required_field()}</span>
</div>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_ports()}</h2>
<p>{m.device_ports_desc()}</p>
<div class="w-full">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each device.expand.ports as _, index}
<DeviceFormPort bind:device {index} />
{/each}
</div>
<button
class="btn max-w-xs {device.expand.ports.length > 0 ? 'mt-4' : ''}"
on:click={() => createEmptyPort()}
type="button">{m.device_ports_add_new()}</button
>
</div>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_link()}</h2>
<p>
{m.device_link_desc()}
</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_link()}</span>
<input
type="url"
placeholder={m.device_link()}
class="input"
bind:value={device.link}
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_link_open()}</span>
<select class="select" bind:value={device.link_open}>
<option value="">{m.device_link_open_no()}</option>
<option value="same_tab">{m.device_link_open_same_tab()}</option>
<option value="new_tab">{m.device_link_open_new_tab()}</option>
</select>
</label>
</fieldset>
</div>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_ping()}</h2>
<p>
<!-- eslint-disable svelte/no-at-html-tags -->
{@html m.device_ping_desc()}
</p>
<fieldset class="fieldset">
<label class="input mt-2">
<span>$:</span>
<input
type="text"
placeholder={m.device_ping_cmd()}
class="grow"
bind:value={device.ping_cmd}
/>
</label>
</fieldset>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_wake()}</h2>
<p>
{m.device_wake_desc()}
<!-- eslint-disable svelte/no-at-html-tags -->
{@html m.settings_ping_interval_desc2()}
</p>
<div class="flex flex-row flex-wrap gap-4">
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_wake_timeout()}</span>
<input
type="number"
min="0"
placeholder={m.device_wake_timeout()}
class="input"
bind:value={device.wake_timeout}
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="input mt-2">
<span>$:</span>
<input
type="text"
placeholder={m.device_wake_cmd()}
class="grow"
bind:value={device.wake_cmd}
/>
</label>
</fieldset>
</div>
<div class="flex flex-row flex-wrap gap-4">
<fieldset
class="fieldset bg-base-100 border-base-300 rounded-box w-fit min-w-64 border p-4"
>
<legend class="fieldset-legend">{m.device_wake_cron_enable()}</legend>
<label class="fieldset-label">
<input
type="checkbox"
bind:checked={device.wake_cron_enabled}
class="toggle toggle-success"
/>
{m.device_wake_cron_enable()}
</label>
<label class="floating-label mt-2">
<span
>{m.device_wake_cron()}
{#if device.wake_cron_enabled}
<span class="text-error">*</span>
{/if}
</span>
<input
type="text"
placeholder={m.device_wake_cron()}
class="input"
bind:value={device.wake_cron}
disabled={!device.wake_cron_enabled}
required={device.wake_cron_enabled}
/>
{#if device.wake_cron_enabled}
<p class="fieldset-label">
{#await validateCron(device.wake_cron)}
<span class="loading loading-spinner loading-xs"></span>
{:then valid}
{valid ? '✅ ' + nextCronDate(device.wake_cron) : m.settings_invalid_cron()}
{/await}
</p>
{/if}
</label>
{#if device.wake_cron_enabled}
<pre class="bg-base-100 text-base-content/80 w-fit rounded p-2"><code
>* * * * * *
| | | | | |
| | | | | day of the week (06)
| | | | month (112)
| | | day of the month (131)
| | hour (023)
| minute (059)
second (059, optional)
</code></pre>
{/if}
</fieldset>
<fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4">
<legend class="fieldset-legend">{m.device_require_confirmation()}</legend>
<label class="fieldset-label">
<input
type="checkbox"
bind:checked={device.wake_confirm}
class="toggle toggle-success"
/>
{m.device_require_confirmation()}
</label>
</fieldset>
</div>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_shutdown()}</h2>
<p>
<!-- eslint-disable svelte/no-at-html-tags -->
{@html m.device_shutdown_desc()}
</p>
<div class="flex flex-row flex-wrap gap-4">
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_shutdown_timeout()}</span>
<input
type="number"
min="0"
placeholder={m.device_shutdown_timeout()}
class="input"
bind:value={device.shutdown_timeout}
/>
</label>
</fieldset>
<fieldset class="fieldset">
<label class="input mt-2">
<span>$:</span>
<input
type="text"
placeholder={m.device_shutdown_cmd()}
class="grow"
bind:value={device.shutdown_cmd}
/>
</label>
</fieldset>
</div>
<p class="font-bold">{m.device_shutdown_examples()}</p>
<div class="mockup-code max-w-fit min-w-0 text-sm">
<pre data-prefix="#" class="italic"><code>{m.device_shutdown_examples_windows()}</code
></pre>
<pre data-prefix="$" class="text-warning"><code
>net rpc shutdown -I 192.168.1.13 -U "user%password"</code
></pre>
</div>
<div class="mockup-code max-w-fit min-w-0 text-sm">
<pre data-prefix="#" class="italic"><code>{m.device_shutdown_examples_linux()}</code></pre>
<pre data-prefix="$" class="text-warning"><code
>sshpass -p password ssh -o "StrictHostKeyChecking=no" user@192.168.1.13 "sudo poweroff"</code
></pre>
</div>
<p>
{m.device_shutdown_cron_desc()}
</p>
<div class="flex flex-row flex-wrap gap-4">
<fieldset
class="fieldset bg-base-100 border-base-300 rounded-box w-fit min-w-64 border p-4"
>
<legend class="fieldset-legend">{m.device_shutdown_cron_enable()}</legend>
<label class="fieldset-label">
<input
type="checkbox"
bind:checked={device.shutdown_cron_enabled}
class="toggle toggle-success"
/>
{m.device_shutdown_cron_enable()}
</label>
<label class="floating-label mt-2">
<span
>{m.device_shutdown_cron()}
{#if device.shutdown_cron_enabled}
<span class="text-error">*</span>
{/if}
</span>
<input
type="text"
placeholder={m.device_shutdown_cron()}
class="input validator"
bind:value={device.shutdown_cron}
disabled={!device.shutdown_cron_enabled}
required={device.shutdown_cron_enabled}
/>
{#if device.shutdown_cron_enabled}
<p class="fieldset-label">
{#await validateCron(device.shutdown_cron)}
<span class="loading loading-spinner loading-xs"></span>
{:then valid}
{valid ? '✅ ' + nextCronDate(device.shutdown_cron) : m.settings_invalid_cron()}
{/await}
</p>
{/if}
</label>
{#if device.shutdown_cron_enabled}
<pre class="bg-base-100 text-base-content/80 w-fit rounded p-2"><code
>* * * * * *
| | | | | |
| | | | | day of the week (06)
| | | | month (112)
| | | day of the month (131)
| | hour (023)
| minute (059)
second (059, optional)
</code></pre>
{/if}
</fieldset>
<fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4">
<legend class="fieldset-legend">{m.device_require_confirmation()}</legend>
<label class="fieldset-label">
<input
type="checkbox"
bind:checked={device.shutdown_confirm}
class="toggle toggle-success"
/>
{m.device_require_confirmation()}
</label>
</fieldset>
</div>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">Sleep-On-LAN</h2>
<p>
<!-- eslint-disable svelte/no-at-html-tags -->
{@html m.device_sol_desc1()}
</p>
<p>
{m.device_sol_desc2()}
</p>
<p class="font-bold">
<!-- eslint-disable svelte/no-at-html-tags -->
{@html m.device_sol_desc3()}
</p>
<div class="flex flex-row flex-wrap gap-4">
<fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4">
<legend class="fieldset-legend">{m.device_sol_enable()}</legend>
<label class="fieldset-label">
<input
type="checkbox"
bind:checked={device.sol_enabled}
class="toggle toggle-success"
/>
{m.device_sol_enable()}
</label>
<label class="floating-label mt-2">
<span
>{m.device_sol_port()}
{#if device.sol_enabled}
<span class="text-error">*</span>
{/if}
</span>
<input
class="input"
type="number"
min="1"
max="65535"
placeholder={m.device_sol_port()}
bind:value={device.sol_port}
disabled={!device.sol_enabled}
required={device.sol_enabled}
/>
</label>
</fieldset>
{#if device.sol_enabled}
<fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-64 border p-4">
<legend class="fieldset-legend">{m.device_sol_authorization()}</legend>
<label class="fieldset-label">
<input type="checkbox" bind:checked={device.sol_auth} class="toggle toggle-success" />
{m.device_sol_authorization()}
</label>
<label class="floating-label mt-2">
<span
>{m.device_sol_user()}
{#if device.sol_auth}
<span class="text-error">*</span>
{/if}
</span>
<input
type="text"
placeholder={m.device_sol_user()}
class="input"
bind:value={device.sol_user}
disabled={!device.sol_auth}
required={device.sol_auth}
/>
</label>
<label class="floating-label mt-2">
<span
>{m.device_sol_password()}
{#if device.sol_auth}
<span class="text-error">*</span>
{/if}
</span>
<input
type="password"
placeholder={m.device_sol_password()}
class="input"
bind:value={device.sol_password}
disabled={!device.sol_auth}
required={device.sol_auth}
/>
</label>
</fieldset>
{/if}
</div>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_password()}</h2>
<p>
<!-- eslint-disable svelte/no-at-html-tags -->
{@html m.device_password_desc()}
</p>
<fieldset class="fieldset">
<label class="floating-label mt-2">
<span>{m.device_password()}</span>
<input
type="text"
placeholder={m.device_password()}
class="input"
maxlength="6"
bind:value={device.password}
/>
</label>
</fieldset>
</div>
</div>
<div class="card bg-base-200 mt-6 w-full shadow-sm">
<div class="card-body">
<h2 class="card-title">{m.device_groups()}</h2>
<p>
{m.device_groups_desc()}
</p>
<div class="flex flex-row flex-wrap gap-2">
{#each deviceGroups as group}
<div class="join">
<div class=" tooltip" data-tip="Delete">
<button
class="btn btn-error join-item"
type="button"
on:click={() => deleteGroup(group)}><Fa icon={faX} /></button
>
</div>
<div
class="btn join-item bg-base-100 hover:bg-base-200"
on:click={() => toggleGroup(group.id)}
role="none"
>
<input
type="checkbox"
class="checkbox"
checked={device.groups.indexOf(group.id) !== -1}
/>
{group.name}
</div>
</div>
{/each}
</div>
<div class="join">
<input
class="input join-item"
placeholder={m.device_groups_placeholder()}
type="text"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addGroup();
}
}}
bind:value={newGroup}
/>
<button class="btn btn-neutral join-item" type="button" on:click={() => addGroup()}
>{m.buttons_add()}</button
>
</div>
</div>
</div>
<div class="card-actions mt-6 justify-end gap-4">
{#if $pocketbase.authStore.isSuperuser || $permission.delete?.includes(device.id)}
<button class="btn btn-error" type="button" on:click={() => deleteModal.showModal()}
><Fa icon={faTrash} />{m.buttons_delete()}</button
>
{/if}
<button class="btn btn-success" type="submit"><Fa icon={faSave} />{m.buttons_save()}</button>
</div>
</form>
<dialog class="modal" bind:this={deleteModal}>
<div class="modal-box">
<h3 class="text-lg font-bold">{m.users_confirm_delete_title()}</h3>
<p class="py-4">{m.users_confirm_delete_desc({ username: device.name })}</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">{m.buttons_cancel()}</button>
<button class="btn btn-error" on:click={() => deleteDevice()}>{m.buttons_delete()}</button>
</form>
</div>
</div>
</dialog>