feat: add sleep-on-lan support, #24 #78 #108 #162

This commit is contained in:
seriousm4x
2023-08-15 17:44:59 +02:00
parent b2f2dfdcf9
commit f8186ba015
9 changed files with 1821 additions and 516 deletions

View File

@@ -0,0 +1,123 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("z5lghx2r3tm45n1")
if err != nil {
return err
}
// add
new_sol_enabled := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "ueieydw5",
"name": "sol_enabled",
"type": "bool",
"required": false,
"unique": false,
"options": {}
}`), new_sol_enabled)
collection.Schema.AddField(new_sol_enabled)
// add
new_sol_auth := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "yfszwhpw",
"name": "sol_auth",
"type": "bool",
"required": false,
"unique": false,
"options": {}
}`), new_sol_auth)
collection.Schema.AddField(new_sol_auth)
// add
new_sol_user := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "dbcmnrmp",
"name": "sol_user",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}`), new_sol_user)
collection.Schema.AddField(new_sol_user)
// add
new_sol_password := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "hvz8stfy",
"name": "sol_password",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}`), new_sol_password)
collection.Schema.AddField(new_sol_password)
// add
new_sol_port := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "6kfqheid",
"name": "sol_port",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": 1,
"max": 65535
}
}`), new_sol_port)
collection.Schema.AddField(new_sol_port)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("z5lghx2r3tm45n1")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("ueieydw5")
// remove
collection.Schema.RemoveField("yfszwhpw")
// remove
collection.Schema.RemoveField("dbcmnrmp")
// remove
collection.Schema.RemoveField("hvz8stfy")
// remove
collection.Schema.RemoveField("6kfqheid")
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,64 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("cti4l8f4mz8df3r")
if err != nil {
return err
}
// update
edit_number := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "8nwuncgg",
"name": "number",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": 1,
"max": 65535
}
}`), edit_number)
collection.Schema.AddField(edit_number)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("cti4l8f4mz8df3r")
if err != nil {
return err
}
// update
edit_number := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "8nwuncgg",
"name": "number",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": null,
"max": 65535
}
}`), edit_number)
collection.Schema.AddField(edit_number)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,52 @@
package networking
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/pocketbase/pocketbase/models"
"github.com/seriousm4x/upsnap/logger"
)
type SolResponse struct {
Message string `json:"message"`
}
func SleepDevice(device *models.Record) (SolResponse, error) {
logger.Info.Println("sleep triggered for", device.GetString("name"))
var solResp SolResponse
var url string
if device.GetBool("sol_auth") {
url = fmt.Sprintf("http://%s:%s@%s:%d/sleep?format=JSON",
device.GetString("sol_user"), device.GetString("sol_password"), device.GetString("ip"), device.GetInt("sol_port"))
} else {
url = fmt.Sprintf("http://%s:%d/sleep?format=JSON", device.GetString("ip"), device.GetInt("sol_port"))
}
resp, err := http.Get(url)
if err != nil {
return solResp, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return solResp, err
}
var solResp SolResponse
if err := json.Unmarshal(body, &solResp); err != nil {
return solResp, err
}
return solResp, errors.New("status code was not 200")
}
return solResp, nil
}

View File

@@ -20,11 +20,11 @@ func HandlerWake(c echo.Context) error {
if err != nil {
return apis.NewNotFoundError("The device does not exist.", err)
}
record.Set("status", "pending")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
go func(*models.Record) {
record.Set("status", "pending")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
if err := networking.WakeDevice(record); err != nil {
logger.Error.Println(err)
record.Set("status", "offline")
@@ -38,16 +38,41 @@ func HandlerWake(c echo.Context) error {
return c.JSON(http.StatusOK, record)
}
func HandlerSleep(c echo.Context) error {
record, err := App.Dao().FindFirstRecordByData("devices", "id", c.PathParam("id"))
if err != nil {
return apis.NewNotFoundError("The device does not exist.", err)
}
record.Set("status", "pending")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
resp, err := networking.SleepDevice(record)
if err != nil {
logger.Error.Println(err)
record.Set("status", "online")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
return apis.NewBadRequestError(resp.Message, nil)
}
record.Set("status", "offline")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
return c.JSON(http.StatusOK, nil)
}
func HandlerShutdown(c echo.Context) error {
record, err := App.Dao().FindFirstRecordByData("devices", "id", c.PathParam("id"))
if err != nil {
return apis.NewNotFoundError("The device does not exist.", err)
}
record.Set("status", "pending")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
go func(*models.Record) {
record.Set("status", "pending")
if err := App.Dao().SaveRecord(record); err != nil {
logger.Error.Println("Failed to save record:", err)
}
if err := networking.ShutdownDevice(record); err != nil {
logger.Error.Println(err)
record.Set("status", "online")

View File

@@ -44,6 +44,16 @@ func StartPocketBase(distDirFS fs.FS) {
},
})
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/upsnap/sleep/:id",
Handler: HandlerSleep,
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(App),
RequireUpSnapPermission(),
},
})
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/upsnap/shutdown/:id",

1829
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,17 @@
<script lang="ts">
import { formatDistance, parseISO } from 'date-fns';
import { pocketbase, isAdmin, backendUrl, permission } from '$lib/stores/pocketbase';
import DeviceCardNic from './DeviceCardNic.svelte';
import { scale } from 'svelte/transition';
import Fa from 'svelte-fa';
import { faCircleArrowDown, faCircleArrowUp, faLock } from '@fortawesome/free-solid-svg-icons';
import { isAdmin, permission } from '$lib/stores/pocketbase';
import {
faBed,
faCircleArrowDown,
faCircleArrowUp,
faLock,
faPen
} from '@fortawesome/free-solid-svg-icons';
import { scale } from 'svelte/transition';
import { formatDistance, parseISO } from 'date-fns';
import toast from 'svelte-french-toast';
import type { Device } from '$lib/types/device';
export let device: Device;
@@ -18,6 +25,16 @@
now = Date.now();
}, 1000);
}
function sleep() {
fetch(`${backendUrl}api/upsnap/sleep/${device.id}`, {
headers: {
Authorization: $pocketbase.authStore.token
}
}).catch((err) => {
toast.error(err.message);
});
}
</script>
<div class="card bg-base-300 shadow-md rounded-3xl" transition:scale={{ delay: 0, duration: 200 }}>
@@ -33,36 +50,54 @@
<!-- TODO: change to nic array once backend supports it -->
<DeviceCardNic {device} />
</ul>
<div class="flex flex-row flex-wrap gap-2">
{#if device.wake_cron_enabled}
<div class="tooltip" data-tip="Wake cron">
<span class="badge badge-success gap-1 p-3"
><Fa icon={faCircleArrowUp} />{device.wake_cron}</span
>
</div>
{/if}
{#if device.shutdown_cron_enabled}
<div class="tooltip" data-tip="Shutdown cron">
<span class="badge badge-error gap-1 p-3"
><Fa icon={faCircleArrowDown} />{device.shutdown_cron}</span
>
</div>
{/if}
{#if device.password}
<div class="tooltip" data-tip="Wake password">
<span class="badge gap-1 p-3"><Fa icon={faLock} />Password</span>
</div>
{/if}
</div>
<div class="card-actions mt-auto">
{#if device.wake_cron_enabled || device.shutdown_cron_enabled || device.password}
<div class="flex flex-row flex-wrap gap-2">
{#if device.wake_cron_enabled}
<div class="tooltip" data-tip="Wake cron">
<span class="badge badge-success gap-1 p-3"
><Fa icon={faCircleArrowUp} />{device.wake_cron}</span
>
</div>
{/if}
{#if device.shutdown_cron_enabled}
<div class="tooltip" data-tip="Shutdown cron">
<span class="badge badge-error gap-1 p-3"
><Fa icon={faCircleArrowDown} />{device.shutdown_cron}</span
>
</div>
{/if}
{#if device.password}
<div class="tooltip" data-tip="Wake password">
<span class="badge gap-1 p-3"><Fa icon={faLock} />Password</span>
</div>
{/if}
</div>
{/if}
<div class="card-actions mt-auto items-center">
<span class="tooltip" data-tip="Last status change: {device.updated}">
{formatDistance(parseISO(device.updated), now, {
includeSeconds: true,
addSuffix: true
})}
</span>
{#if $isAdmin || $permission.update?.includes(device.id)}
<a class="btn btn-sm btn-neutral ms-auto" href="/device/{device.id}">Edit</a>
{#if $isAdmin || $permission.update?.includes(device.id) || $permission.power?.includes(device.id)}
<div class="dropdown dropdown-top dropdown-end bg-base-300 ms-auto">
<label tabindex="-1" class="btn btn-sm m-1" for="more-{device.id}">More</label>
<ul
id="more-{device.id}"
tabindex="-1"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-fit"
>
{#if ($isAdmin || $permission.power?.includes(device.id)) && device.sol_enabled}
<li><button on:click={() => sleep()}><Fa icon={faBed} />Sleep</button></li>
{/if}
{#if $isAdmin || $permission.update?.includes(device.id)}
<li>
<a href="/device/{device.id}"><Fa icon={faPen} />Edit</a>
</li>
{/if}
</ul>
</div>
{/if}
</div>
</div>

View File

@@ -325,6 +325,128 @@
</div>
</div>
</div>
<div class="card w-full bg-base-300 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">Sleep-On-LAN</h2>
<p class="mt-2">
You can put computers to sleep using the <a
class="link"
href="https://github.com/SR-G/sleep-on-lan"
target="_blank">Sleep-On-LAN</a
>
tool. Sleep-On-LAN (SOL) is an external tool/daemon that operates on the PCs you want to put
to sleep, providing a REST endpoint to put the PC to sleep. For instructions on setting up Sleep-On-LAN,
please refer to the
<a href="https://github.com/SR-G/sleep-on-lan#usage" class="link" target="_blank">Usage</a> section.
</p>
<p>
SOL is confiugred to send requests over HTTP instead of UDP to enable authorization and make
requests more reliable.
</p>
<p class="font-bold">
Therefore, please ensure that you include <span class="badge">HTTP:&lt;YOURPORT&gt;</span>
in the <span class="badge">Listeners</span> section of the
<a href="https://github.com/SR-G/sleep-on-lan#configuration" class="link" target="_blank"
>SOL configuration</a
>.
</p>
<div class="flex flex-row flex-wrap gap-4 items-end mt-4">
<div>
<div class="form-control flex flex-row flex-wrap gap-4">
<div class="flex flex-row gap-2 items-center">
<input
id="sol-enable"
type="checkbox"
class="toggle toggle-success"
bind:checked={device.sol_enabled}
/>
<label class="label cursor-pointer" for="sol-enable">
<span class="label-text">Enable Sleep-On-LAN</span>
</label>
</div>
</div>
<div class="form-control flex flex-col">
<label class="label" for="sol-port">
<span class="label-text"
>SOL Port
{#if device.sol_enabled}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
id="sol-port"
type="number"
min="1"
max="65535"
placeholder="8009"
class="input w-80"
bind:value={device.sol_port}
disabled={!device.sol_enabled}
required={device.sol_enabled}
/>
</div>
</div>
{#if device.sol_enabled}
<div>
<div class="form-control flex flex-row flex-wrap gap-4">
<div class="flex flex-row gap-2 items-center">
<input
id="sol-auth"
type="checkbox"
class="toggle toggle-success"
bind:checked={device.sol_auth}
/>
<label class="label cursor-pointer" for="sol-auth">
<span class="label-text">Authorization</span>
</label>
</div>
</div>
<div class="form-control flex flex-col">
<label class="label" for="sol-user">
<span class="label-text"
>SOL User
{#if device.sol_auth}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
id="sol-user"
type="text"
placeholder="username"
class="input w-80"
bind:value={device.sol_user}
disabled={!device.sol_auth}
required={device.sol_auth}
/>
</div>
</div>
<div class="form-control flex flex-col">
<label class="label" for="sol-password">
<span class="label-text"
>SOL Password
{#if device.sol_auth}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
id="sol-password"
type="password"
placeholder="password"
class="input w-80"
bind:value={device.sol_password}
disabled={!device.sol_auth}
required={device.sol_auth}
/>
</div>
{/if}
</div>
</div>
</div>
<div class="card w-full bg-base-300 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">Shutdown</h2>

View File

@@ -20,6 +20,11 @@ export type Device = Record & {
groups: Group[];
};
created_by: string;
sol_enabled: boolean;
sol_auth: boolean;
sol_user: string;
sol_passwort: string;
sol_port: number;
};
export type Port = Record & {