first commit for tailwind rewrite

- uses daisyUI
- signup, login and logout is done (need some more testing)
This commit is contained in:
seriousm4x
2023-07-22 10:27:31 +02:00
parent 0dbf7033f4
commit 4d682e073f
40 changed files with 1039 additions and 2341 deletions

View File

@@ -4,11 +4,10 @@ node_modules
/.svelte-kit
/package
.env
.env.\*
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@@ -1,23 +1,30 @@
module.exports = {
root: true,
extends: ['plugin:svelte/recommended', 'prettier'],
parser: '@typescript-eslint/parser',
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

10
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.husky/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && npx lint-staged

View File

@@ -1 +1,2 @@
engine-strict=true
resolution-mode=highest

View File

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

View File

@@ -1,4 +1,5 @@
{
<<<<<<< HEAD
"name": "frontend",
"version": "0.0.1",
"private": true,
@@ -41,4 +42,50 @@
"*.{js,svelte}": "eslint --fix --ignore-path ../.gitignore .",
"*.{js,css,md,svelte,scss}": "prettier --write --ignore-path ../.gitignore --plugin-search-dir=. ."
}
=======
"name": "upsnap-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint --fix --cache --ignore-path ../.gitignore .",
"format": "prettier --write --cache --cache-strategy content --ignore-path ../.gitignore --plugin-search-dir=. .",
"prepare": "husky install"
},
"devDependencies": {
"@sveltejs/adapter-static": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
"daisyui": "^3.3.1",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"husky": "^8.0.0",
"postcss": "^8.4.27",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module",
"lint-staged": {
"*.{js,svelte}": "eslint --fix --cache --ignore-path ../.gitignore .",
"*.{js,css,md,svelte,scss}": "prettier --write --cache --cache-strategy content --ignore-path ../.gitignore --plugin-search-dir=. ."
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"pocketbase": "^0.15.3",
"svelte-fa": "^3.0.4"
}
>>>>>>> 5340d83 (first commit for tailwind rewrite)
}

794
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

3
frontend/src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

12
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="h-100">
<div style="display: contents">%sveltekit.body%</div>
</body>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,163 +0,0 @@
<script>
import { dev } from '$app/environment';
import { parseISO, formatDistance } from 'date-fns';
import { pocketbase } from '@stores/pocketbase';
import Fa from 'svelte-fa/src/fa.svelte';
import {
faPowerOff,
faEllipsisVertical,
faCircle,
faCircleUp,
faCircleDown,
faLock
} from '@fortawesome/free-solid-svg-icons';
import { sortPorts } from '../sorts';
export let device;
export let now;
function shutdown() {
fetch(`${dev ? 'http://localhost:8090' : ''}/api/upsnap/shutdown/${device.id}`, {
headers: {
Authorization: $pocketbase.authStore.baseToken
}
});
}
function wake() {
fetch(`${dev ? 'http://localhost:8090' : ''}/api/upsnap/wake/${device.id}`, {
headers: {
Authorization: $pocketbase.authStore.baseToken
}
});
}
</script>
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3 g-4">
<div
class="card border-0 rounded-5 px-3 py-2 shadow-sm"
class:offline={device.status == 'offline' ? true : false}
class:online={device.status == 'online' ? true : false}
class:pending={device.status == 'pending' ? true : false}
>
<div class="card-body">
<div class="row">
<div class="col-auto me-auto">
{#if device.status === 'offline'}
<span
class="text-danger"
role="none"
on:click={() => wake()}
on:keydown={() => wake()}
>
<div>
<Fa icon={faPowerOff} class="fs-4 power-hover" />
</div>
</span>
{:else if device.status === 'online'}
{#if device.shutdown_cmd !== ''}
<span
class="text-success"
role="none"
on:click={() => shutdown()}
on:keydown={() => shutdown()}
>
<div>
<Fa icon={faPowerOff} class="fs-4 power-hover" />
</div>
</span>
{:else}
<span class="text-success">
<div>
<Fa icon={faPowerOff} class="fs-4" />
</div>
</span>
{/if}
{:else if device.status === 'pending'}
<div
class="spinner-border text-warning"
style="width: 23px;height: 23px;"
role="status"
>
<span class="visually-hidden">Loading...</span>
</div>
{/if}
</div>
<div class="col-auto fs-5">
<a
href="/device/{device.id}"
class="text-reset text-center ellipsis power-hover"
>
<Fa icon={faEllipsisVertical} />
</a>
</div>
</div>
{#if device.link}
<p class="m-0 fw-bold fs-5 text-body-emphasis">
<a class="text-reset" target="_blank" rel="noreferrer" href={device.link}
>{device.name}</a
>
</p>
{:else}
<p class="m-0 fw-bold fs-5 text-body-emphasis">{device.name}</p>
{/if}
<p class="text-muted mb-2">{device.ip}</p>
{#if device?.expand?.ports}
<div class="mb-2">
{#each device.expand.ports.sort(sortPorts) as port}
<p class="m-0">
<Fa
icon={faCircle}
class="me-1 {port.status ? 'port-up' : 'port-down'}"
/>{port.name}
<span class="text-muted">({port.number})</span>
</p>
{/each}
</div>
{/if}
{#if device.wake_cron !== ''}
<span
class="badge rounded-pill {device.wake_cron_enabled
? 'text-bg-success'
: 'text-bg-danger'}"
data-toggle="tooltip"
title="Wake cron {device.wake_cron_enabled ? 'enabled' : 'disabled'}"
>
<Fa icon={faCircleUp} class="me-1" />
{device.wake_cron}
</span>
{/if}
{#if device.shutdown_cron !== ''}
<span
class="badge rounded-pill {device.shutdown_cron_enabled
? 'text-bg-success'
: 'text-bg-danger'}"
data-toggle="tooltip"
title="Shutdown cron {device.shutdown_cron_enabled ? 'enabled' : 'disabled'}"
>
<Fa icon={faCircleDown} class="me-1" />
{device.shutdown_cron}
</span>
{/if}
{#if device.password !== ''}
<span
class="badge rounded-pill text-bg-secondary"
data-toggle="tooltip"
title="Wake password set"
>
<Fa icon={faLock} class="mx-1" />
</span>
{/if}
<p
class="text-muted m-0"
data-toggle="tooltip"
title="Last status change: {device.updated}"
>
{formatDistance(parseISO(device.updated), now, {
includeSeconds: true,
addSuffix: true
})}
</p>
</div>
</div>
</div>

View File

@@ -1,418 +0,0 @@
<script>
import { goto } from '$app/navigation';
import { pocketbase, devices, groups } from '@stores/pocketbase';
import Fa from 'svelte-fa/src/fa.svelte';
import { faEye, faEyeSlash, faPlus, faTrashCan } from '@fortawesome/free-solid-svg-icons';
export let device;
export let mode;
let timeout;
let button = {
state: 'none',
error: ''
};
let deleteButton = {
state: 'none',
error: ''
};
let newPort = {
name: '',
number: null
};
let passwordShow = false;
$: passwordType = passwordShow ? 'text' : 'password';
async function addOrUpdateDevice() {
button.state = 'waiting';
try {
// validate password length
if (
device.password.length != 0 &&
device.password.length != 4 &&
device.password.length != 6
) {
throw 'Password must be 0, 4 or 6 characters long';
}
// add ports not in db
for (let i = 0; i < device.expand.ports.length; i++) {
const port = device.expand.ports[i];
if (!port.id) {
const result = await $pocketbase.collection('ports').create(port);
device.ports = [...device.ports, result.id];
} else {
await $pocketbase.collection('ports').update(port.id, port);
}
}
// create or update device
if (mode === 'add') {
await $pocketbase.collection('devices').create(device);
} else {
await $pocketbase.collection('devices').update(device.id, device);
}
// show button with timeout
clearTimeout(timeout);
timeout = setTimeout(() => {
button.state = 'none';
}, 3000);
button.state = 'success';
} catch (error) {
clearTimeout(timeout);
setTimeout(() => {
button.error = '';
button.state = 'none';
}, 3000);
button.error = error;
button.state = 'failed';
}
}
async function deleteDevice() {
deleteButton.state = 'waiting';
try {
await $pocketbase.collection('devices').delete(device.id);
device.ports.forEach(async (port) => {
await $pocketbase.collection('ports').delete(port);
});
delete $devices[device.id];
goto('/');
} catch (error) {
clearTimeout(timeout);
timeout = setTimeout(() => {
deleteButton.error = '';
deleteButton.state = 'none';
}, 3000);
deleteButton.error = error;
deleteButton.state = 'failed';
}
}
async function deletePort(idx) {
// delete port from db if it has an id
// ports with id exist in db, ports without id are not created yet
const port = device.expand.ports[idx];
if (port.id) {
await $pocketbase.collection('ports').delete(port.id);
const i = device.ports.indexOf(port.id);
if (i !== -1) {
device.ports.splice(i, 1);
device.ports = device.ports;
}
}
device.expand.ports.splice(idx, 1);
device.expand.ports = device.expand.ports;
}
function addPort() {
device.expand.ports = [...device.expand.ports, JSON.parse(JSON.stringify(newPort))];
newPort.name = '';
newPort.number = null;
}
function onPasswordInput(event) {
device.password = event.target.value;
}
function toggleGroup(grpId) {
const i = device.groups.indexOf(grpId);
if (i !== -1) {
device.groups.splice(i, 1);
device.groups = device.groups;
} else {
device.groups = [...device.groups, grpId];
}
}
</script>
<section class="m-0 mt-4 p-4 shadow-sm">
<h3 class="mb-3 text-body-emphasis">{mode === 'add' ? 'Add new device' : device.name}</h3>
<div class="row">
<div class="col-md-6">
<form on:submit|preventDefault={addOrUpdateDevice}>
<h5>Required:</h5>
<div class="input-group mb-1">
<span class="input-group-text">Name</span>
<input
class="form-control"
placeholder="Office Pc"
aria-label="Name"
aria-describedby="addon-wrapping"
type="text"
required
bind:value={device.name}
/>
</div>
<div class="input-group mb-1">
<span class="input-group-text">IP</span>
<input
class="form-control"
placeholder="192.168.1.34"
aria-label="IP"
aria-describedby="addon-wrapping"
type="text"
required
bind:value={device.ip}
/>
</div>
<div class="input-group mb-1">
<span class="input-group-text">MAC</span>
<input
class="form-control"
placeholder="aa:bb:cc:dd:ee:ff"
aria-label="MAC"
aria-describedby="addon-wrapping"
type="text"
required
bind:value={device.mac}
/>
</div>
<div class="input-group">
<span class="input-group-text">Netmask</span>
<input
class="form-control"
placeholder="Most likely 255.255.255.0 or 255.255.0.0"
aria-label="Netmask"
aria-describedby="addon-wrapping"
type="text"
required
bind:value={device.netmask}
/>
</div>
<h5 class="mt-4">Optional:</h5>
{#if device?.expand?.ports}
{#each device.expand.ports as port, idx}
<div class="input-group mb-1">
<span class="input-group-text">Port</span>
<input
type="text"
aria-label="Name"
class="form-control"
placeholder="Name"
bind:value={port.name}
/>
<input
type="number"
min="0"
max="65535"
aria-label="Number"
class="form-control"
placeholder="Number"
bind:value={port.number}
/>
<button
type="button"
class="btn btn-outline-secondary"
on:click={() => deletePort(idx)}
>
<Fa icon={faTrashCan} />
</button>
</div>
{/each}
{/if}
<div class="input-group mb-1">
<span class="input-group-text">Port</span>
<input
type="text"
aria-label="Name"
class="form-control"
placeholder="Name"
bind:value={newPort.name}
/>
<input
type="number"
min="0"
max="65535"
aria-label="Number"
class="form-control"
placeholder="Number"
bind:value={newPort.number}
/>
<button
type="button"
class="btn btn-outline-secondary"
on:click={() => addPort()}
>
<Fa icon={faPlus} />
</button>
</div>
<div class="input-group mb-1">
<span class="input-group-text">Link</span>
<input
class="form-control"
placeholder="Clickable link on device card"
aria-label="Link"
aria-describedby="addon-wrapping"
type="url"
bind:value={device.link}
/>
</div>
<div class="input-group mb-1">
<span class="input-group-text">Wake Cron<sup>(1)</sup></span>
<input
class="form-control"
placeholder="Automatically wake device with cron"
aria-label="Wake Cron"
aria-describedby="addon-wrapping"
type="text"
bind:value={device.wake_cron}
/>
<div class="input-group-text form-switch">
<label class="form-check-label" for="wake-cron-enabled">Enable</label>
<input
id="wake-cron-enabled"
class="form-check-input mt-0 ms-1"
type="checkbox"
role="switch"
aria-label="Enable/Disable wake cron"
bind:checked={device.wake_cron_enabled}
/>
</div>
</div>
<div class="input-group mb-1">
<span class="input-group-text">Shutdown Cron<sup>(1)</sup></span>
<input
class="form-control"
placeholder="Automatically shutdown device with cron"
aria-label="Shutdown Cron"
aria-describedby="addon-wrapping"
type="text"
bind:value={device.shutdown_cron}
/>
<div class="input-group-text form-switch">
<label class="form-check-label" for="wake-shutdown-enabled">Enable</label>
<input
id="wake-shutdown-enabled"
class="form-check-input mt-0 ms-1"
type="checkbox"
role="switch"
aria-label="Enable/Disable wake cron"
bind:checked={device.shutdown_cron_enabled}
/>
</div>
</div>
<div class="input-group mb-1">
<span class="input-group-text">Shutdown Cmd<sup>(2)</sup></span>
<input
class="form-control"
placeholder="Command to shutdown device"
aria-label="Shutdown Cmd"
aria-describedby="addon-wrapping"
type="text"
bind:value={device.shutdown_cmd}
/>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Password<sup>(3)</sup></span>
<input
class="form-control"
placeholder="BIOS password for wol"
aria-label="IP"
aria-describedby="addon-wrapping"
type={passwordType}
value={device.password}
maxlength="6"
on:input={onPasswordInput}
/>
<button
class="btn btn-outline-secondary"
type="button"
on:click={() => (passwordShow = !passwordShow)}
>
<Fa icon={passwordShow ? faEyeSlash : faEye} />
</button>
</div>
{#if groups}
<h5 class="mt-4">Groups:</h5>
<div class="input-group mb-3">
{#each $groups as grp}
<span
class="badge rounded-pill fs-6 cursor-pointer me-2 {device?.groups?.includes(
grp.id
)
? 'text-bg-success'
: 'text-bg-secondary text-white-50'} "
role="none"
on:click={() => toggleGroup(grp.id)}
on:keydown={() => toggleGroup(grp.id)}>{grp.name}</span
>
{/each}
</div>
{/if}
<button
type="submit"
class="btn btn-secondary"
class:btn-success={button.state === 'success' ? true : false}
class:btn-warning={button.state === 'waiting' ? true : false}
class:btn-danger={button.state === 'failed' ? true : false}
disabled={button.state !== 'none' ? true : false}
>
{#if button.state === 'none'}
{mode === 'add' ? 'Add device' : 'Save device'}
{:else if button.state === 'success'}
{mode === 'add' ? 'Added successfully' : 'Saved successfully'}
{:else if button.state === 'waiting'}
Waiting
{:else if button.state === 'failed'}
Failed: {button.error}
{/if}
</button>
</form>
</div>
<div class="col-md-6 d-flex align-items-start flex-column">
<div class="callout callout-info mb-auto">
<h5>Optional:</h5>
<p class="m-0">
(1) Learn more about the correct syntax for cron on
<a href="https://en.wikipedia.org/wiki/Cron" target="_blank" rel="noreferrer"
>wikipedia</a
>
or refer to the
<a
target="_blank"
rel="noreferrer"
href="https://pkg.go.dev/github.com/robfig/cron">package documentation</a
>
</p>
<p class="m-0">
(2) Shell command to be executed. "net rpc", "sshpass" and "curl" are available.
e.g.:
</p>
<ul>
<li>
Windows: <code>net rpc shutdown -I 192.168.1.13 -U "user%password"</code>
</li>
<li>
Linux: <code
>sshpass -p your_password ssh -o "StrictHostKeyChecking=no"
user@hostname "sudo poweroff"</code
>
</li>
</ul>
<p class="m-0">
(3) Some network cards have the option to set a password for magic packets, also
called "SecureON". Password can only be 0, 4 or 6 characters in length.
</p>
</div>
{#if mode === 'edit'}
<div class="text-end mt-3 align-self-end">
<button
class="btn btn-danger"
on:click={() => deleteDevice()}
on:keydown={() => deleteDevice()}
>
{#if deleteButton.state === 'none'}
Delete device
{:else if deleteButton.state === 'waiting'}
Waiting
{:else if deleteButton.state === 'failed'}
Failed: {deleteButton.error}
{/if}
</button>
</div>
{/if}
</div>
</div>
</section>

View File

@@ -1,237 +0,0 @@
<script>
import { dev } from '$app/environment';
import { page } from '$app/stores';
import { pocketbase, authorizedStore, settings_public } from '@stores/pocketbase';
let username;
let password;
let passwordConfirm;
let isAdmin;
let callout = {
hidden: true,
level: 'danger',
title: '',
msg: ''
};
$: if ($settings_public.setup_completed === false) {
callout.title = '';
callout.msg = 'No admin account has been set up. Please create one.';
callout.level = 'info';
callout.hidden = false;
}
async function loginUserPass() {
if (isAdmin) {
$pocketbase.admins
.authWithPassword(username, password)
.then(() => {
authorizedStore.set(true);
})
.catch((err) => {
authorizedStore.set(false);
callout.level = 'danger';
callout.title = err.status;
callout.msg = err.message;
callout.hidden = false;
});
} else {
$pocketbase
.collection('users')
.authWithPassword(username, password)
.then(() => {
authorizedStore.set(true);
})
.catch((err) => {
authorizedStore.set(false);
callout.level = 'danger';
callout.title = err.status;
callout.msg = err.message;
callout.hidden = false;
});
}
}
async function loginOIDC() {
$pocketbase
.collection('users')
.authWithOAuth2({ provider: 'oidc' })
.then(() => {
authorizedStore.set(true);
})
.catch((err) => {
authorizedStore.set(false);
callout.level = 'danger';
callout.title = err.status;
callout.msg = err.message;
callout.hidden = false;
});
}
async function register() {
if (password !== passwordConfirm) {
callout.level = 'danger';
callout.title = "Passwords don't match";
callout.msg = 'Please try again';
callout.hidden = false;
return;
} else {
callout.hidden = true;
}
$pocketbase.admins
.create({
email: username,
password: password,
passwordConfirm: passwordConfirm
})
.then(() => {
$pocketbase.admins
.authWithPassword(username, password)
.then(() => {
authorizedStore.set(true);
})
.catch((err) => {
return err;
});
})
.catch((err) => {
authorizedStore.set(false);
callout.level = 'danger';
callout.title = err.status;
callout.msg = err.message;
callout.hidden = false;
});
}
</script>
<div class="container text-dark-emphasis">
<div class="row justify-content-center align-items-center">
<div class="col text-center p-3 p-md-5">
<img
src={$settings_public.favicon !== ''
? `${dev ? $pocketbase.baseUrl : ''}/api/files/settings_public/${
$settings_public.id
}/${$settings_public.favicon}`
: '/gopher.svg'}
alt={$settings_public.website_title ? $settings_public.website_title : 'UpSnap'}
class="logo pe-none user-select-none"
/>
<h2 class="text-dark-emphasis fw-bold text-center mt-3">
{$settings_public.setup_completed === true ? 'Login' : 'Register'}
</h2>
</div>
</div>
<div class="row justify-content-center align-items-center p-2">
<div class="col-md-6 col-lg-5 p-4 login rounded-5 shadow-sm">
<div class="callout callout-{callout.level} mt-0" class:d-none={callout.hidden}>
{#if callout.title !== ''}
<h5>{callout.title}</h5>
{/if}
{#if callout.msg !== ''}
<p class="m-0">{callout.msg}</p>
{/if}
</div>
{#if $settings_public.setup_completed === true}
<form class="w-100" on:submit|preventDefault={loginUserPass}>
<div class="mb-3">
<label for="username" class="form-label">Username or email</label>
<input
type="text"
class="form-control"
id="username"
placeholder="user@example.com"
bind:value={username}
required
/>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
type="password"
class="form-control"
id="password"
placeholder="secret-password"
bind:value={password}
required
/>
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="flexSwitchCheckChecked"
bind:checked={isAdmin}
/>
<label class="form-check-label" for="flexSwitchCheckChecked"
>Admin login</label
>
</div>
<button class="btn btn-secondary w-100" type="submit">Login</button>
</form>
<p class="text-center m-0 mt-3">
<!-- hacky way of linking a non existing route in svelte -->
<a href="/_/" rel="external" class="btn btn-outline-secondary btn-sm"
>Manage users</a
>
<button
class="btn btn-outline-secondary btn-sm"
data-toggle="tooltip"
title="You can setup OIDC at {$page.url.host}/_/#/settings/auth-providers"
on:click={() => loginOIDC()}
on:keydown={() => loginOIDC()}>Login with OIDC</button
>
</p>
{:else}
<form class="w-100" on:submit|preventDefault={register}>
<div class="mb-3">
<label for="username" class="form-label">Email</label>
<input
type="email"
class="form-control"
id="username"
placeholder="user@example.com"
bind:value={username}
required
/>
</div>
<div class="mb-3">
<label for="password" class="form-label"
>Password (min. 10 characters)</label
>
<input
type="password"
class="form-control"
id="password"
placeholder="secret-password"
minlength="10"
bind:value={password}
required
/>
</div>
<div class="mb-3">
<label for="passwordConfirm" class="form-label">Password confirm</label>
<input
type="password"
class="form-control"
id="passwordConfirm"
placeholder="secret-password"
minlength="10"
bind:value={passwordConfirm}
required
/>
</div>
<button class="btn btn-secondary w-100" type="submit">Create and login</button>
</form>
{/if}
</div>
</div>
</div>
<style lang="scss">
.logo {
width: 40%;
min-width: 50px;
max-width: 200px;
}
</style>

View File

@@ -1,146 +0,0 @@
<script>
import { dev } from '$app/environment';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { theme } from '@stores/theme';
import { pocketbase, authorizedStore, settings_public } from '@stores/pocketbase';
import Fa from 'svelte-fa/src/fa.svelte';
import {
faSun,
faMoon,
faCircleHalfStroke,
faBrush,
faRightFromBracket
} from '@fortawesome/free-solid-svg-icons';
let userInfo = {
usernameOrEmail: '',
role: ''
};
onMount(async () => {
if ($pocketbase.authStore.baseModel?.collectionName === 'users') {
userInfo.role = 'user';
} else {
userInfo.role = 'admin';
}
if ($pocketbase.authStore.baseModel?.username) {
userInfo.usernameOrEmail = $pocketbase.authStore.baseModel.username;
} else {
userInfo.usernameOrEmail = $pocketbase.authStore.baseModel.email;
}
});
async function logout() {
$pocketbase.authStore.clear();
authorizedStore.set(false);
}
</script>
<nav class="navbar navbar-expand-sm pt-0">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<img
src={$settings_public.favicon !== ''
? `${dev ? $pocketbase.baseUrl : ''}/api/files/settings_public/${
$settings_public.id
}/${$settings_public.favicon}`
: '/gopher.svg'}
alt={$settings_public.website_title ? $settings_public.website_title : 'UpSnap'}
width="45"
height="45"
class:me-2={$settings_public.website_title !== ''}
/>{$settings_public.website_title ? $settings_public.website_title : ''}
</a>
<button
class="navbar-toggler border-0"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav">
{#if userInfo.role !== 'user'}
<li class="nav-item">
<a
class="nav-link"
class:active={$page.url.pathname === '/' ? true : false}
href="/">Home</a
>
</li>
<li class="nav-item me-3">
<a
class="nav-link"
class:active={$page.url.pathname === '/settings/' ? true : false}
href="/settings/">Settings</a
>
</li>
{/if}
<li class="nav-item dropdown">
<div
class="nav-link dropdown-toggle"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<Fa icon={faBrush} class="me-2" />
Theme
</div>
<ul class="dropdown-menu border-0 p-1 shadow-sm mb-2">
<li>
<div
class="dropdown-item"
class:active={$theme === 'light'}
role="none"
on:click={() => ($theme = 'light')}
on:keydown={() => ($theme = 'light')}
>
<Fa icon={faSun} class="me-2" />
Light
</div>
</li>
<li>
<div
class="dropdown-item"
class:active={$theme === 'dark'}
role="none"
on:click={() => ($theme = 'dark')}
on:keydown={() => ($theme = 'dark')}
>
<Fa icon={faMoon} class="me-2" />
Dark
</div>
</li>
<li>
<div
class="dropdown-item"
class:active={$theme === 'auto'}
role="none"
on:click={() => ($theme = 'auto')}
on:keydown={() => ($theme = 'auto')}
>
<Fa icon={faCircleHalfStroke} class="me-2" />
Auto
</div>
</li>
</ul>
</li>
</ul>
<div class="ms-auto d-flex">
<button
class="text-dark-emphasis nav-link active border-0"
data-toggle="tooltip"
title="Logged in as {userInfo.role} &quot;{userInfo.usernameOrEmail}&quot;"
on:click={() => logout()}
>
<Fa icon={faRightFromBracket} class="me-2" />Logout ({userInfo.usernameOrEmail})
</button>
</div>
</div>
</div>
</nav>

View File

@@ -1,10 +0,0 @@
<script>
import { fly } from 'svelte/transition';
export let url;
</script>
{#key url}
<div in:fly={{ y: 50, duration: 300 }}>
<slot />
</div>
{/key}

View File

@@ -1,16 +0,0 @@
<script>
import { pocketbase } from '@stores/pocketbase';
let isUser = false;
$: isUser = $pocketbase.authStore.baseModel?.collectionName === 'users';
</script>
{#if isUser}
<div class="row justify-content-center align-items-center">
<div class="col-auto">
<div class="callout callout-danger m-0">
Logged in as read only user. To make changes, please log in as admin.
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pocketbase } from '$lib/stores/pocketbase';
import { settingsPub } from '$lib/stores/settings';
$: console.log($pocketbase);
$: avatar = $pocketbase.authStore?.model?.avatar ? $pocketbase.authStore?.model.avatar : '0';
function logout() {
$pocketbase.authStore.clear();
goto('/login');
}
</script>
<div class="navbar bg-base-100">
<div class="flex-1">
<a class="btn btn-ghost normal-case text-xl" href="/">
<img
src={$settingsPub?.collectionId && $settingsPub?.favicon
? `/api/files/settings_public/${$settingsPub?.collectionId}/${$settingsPub?.favicon}`
: '/gopher.svg'}
alt={$settingsPub?.website_title ? $settingsPub?.website_title : 'UpSnap'}
width="45"
height="45"
/>{$settingsPub?.website_title ? $settingsPub?.website_title : ''}
</a>
</div>
<div class="flex-none">
{#if $pocketbase.authStore?.model !== null}
<div class="dropdown dropdown-end">
<label tabindex="-1" class="btn btn-ghost btn-circle avatar" for="avatar">
<div class="w-10 rounded-full" id="avatar">
<img
src="{$pocketbase.baseUrl}/_/images/avatars/avatar{avatar}.svg"
alt="Profile pic"
/>
</div>
</label>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-40"
>
<li>
<a class="justify-between" href="/profile/{$pocketbase.authStore.model?.id}"
>Edit profile</a
>
</li>
<li>
<div on:click={() => logout()} on:keydown={() => logout()} role="none">Logout</div>
</li>
</ul>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,7 @@
export function toggleVisibility(el: HTMLInputElement) {
if (el.type === 'password') {
el.type = 'text';
} else {
el.type = 'password';
}
}

View File

@@ -0,0 +1,13 @@
import { writable } from 'svelte/store';
import PocketBase from 'pocketbase';
import type AdminAuthResponse from 'pocketbase';
// set backend url based on environment
export const backendUrl = import.meta.env.DEV ? 'http://127.0.0.1:8090' : '';
// connect to backend
const pb = new PocketBase(backendUrl);
pb.autoCancellation(false);
// export stores
export const pocketbase = writable(pb);

View File

@@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
import type { SettingsPublic, SettingsPrivate } from '$lib/types/settings';
export const settingsPub = writable<SettingsPublic>();
export const settingsPriv = writable<SettingsPrivate>();

View File

@@ -0,0 +1,11 @@
export type SettingsPublic = {
collectionId: string;
favicon: string;
setup_completed: boolean;
website_title: string;
};
export type SettingsPrivate = {
interval: number;
scan_range: string;
};

View File

@@ -1,2 +0,0 @@
export const prerender = true;
export const trailingSlash = 'always';

View File

@@ -1,148 +1,67 @@
<script>
import { dev } from '$app/environment';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import Navbar from '@components/Navbar.svelte';
import Login from '@components/Login.svelte';
import Transition from '@components/Transition.svelte';
import { theme } from '@stores/theme';
import {
pocketbase,
authorizedStore,
devices,
groups,
settings_private,
settings_public
} from '@stores/pocketbase';
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { pocketbase } from '$lib/stores/pocketbase';
import { settingsPub } from '$lib/stores/settings';
import Navbar from '$lib/components/Navbar.svelte';
let favicon;
let preferesDark;
let isAuth = false;
onMount(async () => {
// set settingsPub store on load
if (!$settingsPub) {
const res = await $pocketbase.collection('settings_public').getList(1, 1);
settingsPub.set({
collectionId: res.items[0].collectionId,
favicon: res.items[0].favicon,
setup_completed: res.items[0].setup_completed,
website_title: res.items[0].website_title
});
}
onMount(async () => {
// import bootstrap js
import('bootstrap/js/dist/dropdown');
import('bootstrap/js/dist/collapse');
// redirect to welcome page if setup is not completed
if (!$settingsPub?.setup_completed && $page.url.pathname !== '/welcome') {
goto('/welcome');
}
// set dark mode
preferesDark = window.matchMedia('(prefers-color-scheme: dark)');
preferesDark.addEventListener('change', (e) => {
if ($theme === 'auto') {
document.documentElement.setAttribute(
'data-bs-theme',
e.matches ? 'dark' : 'light'
);
}
});
// load auth from localstorage
const pbCookie = localStorage.getItem('pocketbase_auth');
if (!pbCookie) {
goto('/login');
return;
}
theme.subscribe((t) => {
localStorage.setItem('theme', t);
if (t === 'auto') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', t);
});
$pocketbase.authStore.loadFromCookie('pb_auth=' + pbCookie);
if (!$pocketbase.authStore.isValid) {
goto('/login');
}
authorizedStore.subscribe((state) => {
isAuth = state;
});
// load public settings and subscribe to changes and change favicon
const settingsPublicRes = await $pocketbase.collection('settings_public').getList(1, 1);
settings_public.set(settingsPublicRes.items[0]);
updateSettingsPublic();
$pocketbase.collection('settings_public').subscribe('*', function (e) {
settings_public.set(e.record);
updateSettingsPublic();
});
// load auth from localstorage
const pbCookie = localStorage.getItem('pocketbase_auth');
if (pbCookie) {
$pocketbase.authStore.loadFromCookie('pb_auth=' + pbCookie);
// try to refresh auth token if valid
if ($pocketbase.authStore.isValid) {
if ($pocketbase.authStore.model?.collectionName === 'users') {
await $pocketbase.collection('users').authRefresh();
} else {
await $pocketbase.admins.authRefresh();
}
}
authorizedStore.set($pocketbase.authStore.isValid);
}
});
function updateSettingsPublic() {
favicon.href =
$settings_public.favicon === ''
? '/gopher.svg'
: `${dev ? $pocketbase.baseUrl : ''}/api/files/settings_public/${
$settings_public.id
}/${$settings_public.favicon}`;
document.title =
$settings_public.website_title === '' ? 'UpSnap' : $settings_public.website_title;
}
async function getSettingsPrivateAndDevices() {
if ($pocketbase.authStore.model?.collectionName !== 'users') {
const settingsPrivateRes = await $pocketbase
.collection('settings_private')
.getList(1, 1);
settings_private.set(settingsPrivateRes.items[0]);
}
// get all devices from pb and save in svelte store
let tempDevices = {};
const devicesRes = await $pocketbase.collection('devices').getFullList(-1, {
sort: 'name',
expand: 'ports,groups'
});
devicesRes.forEach((device) => {
tempDevices[device.id] = device;
});
devices.set(tempDevices);
// get all groups from pb and save in svelte store
const groupsRes = await $pocketbase.collection('groups').getFullList(-1, {
sort: 'name'
});
groups.set(groupsRes);
}
$: if (isAuth) {
getSettingsPrivateAndDevices();
}
if ($pocketbase.authStore.model?.collectionName === 'users') {
$pocketbase
.collection('users')
.authRefresh()
.catch(() => {
$pocketbase.authStore.clear();
});
} else {
$pocketbase.admins.authRefresh().catch(() => {
$pocketbase.authStore.clear();
});
}
});
</script>
<svelte:head>
<link rel="shortcut icon" href="/gopher.svg" bind:this={favicon} />
<script>
if (document) {
preferesDark = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
let t = localStorage.getItem('theme');
if (!t) {
localStorage.setItem('theme', 'auto');
t = preferesDark;
} else if (t === 'auto') {
t = preferesDark;
}
document.documentElement.setAttribute('data-bs-theme', t);
}
</script>
<link
rel="shortcut icon"
href={$settingsPub?.collectionId && $settingsPub?.favicon
? `/api/files/settings_public/${$settingsPub?.collectionId}/${$settingsPub?.favicon}`
: '/gopher.svg'}
/>
</svelte:head>
{#if isAuth}
<Navbar />
<Transition url={$page.url}>
<slot />
</Transition>
{:else}
<Login />
{#if $pocketbase.authStore.isValid}
<Navbar />
{/if}
<style lang="scss" global>
@import '../scss/main.scss';
</style>
<slot />

View File

@@ -1,95 +0,0 @@
<script>
import { onMount } from 'svelte';
import DeviceCard from '@components/DeviceCard.svelte';
import { pocketbase, devices } from '@stores/pocketbase';
import { sortDevices, sortGroups } from '../sorts';
onMount(async () => {
// subscribe to database events
$pocketbase.collection('devices').subscribe('*', async (e) => {
if (e.action === 'create') {
$devices[e.record.id] = e.record;
} else if (e.action === 'update') {
const device = await $pocketbase.collection('devices').getOne(e.record.id, {
expand: 'ports,groups'
});
$devices[device.id] = device;
} else if (e.action === 'delete') {
delete $devices[e.record.id];
}
});
$pocketbase.collection('ports').subscribe('*', async (e) => {
if (e.action === 'update') {
const device = Object.values($devices).find((dev) =>
dev.ports.includes(e.record.id)
);
if (!device) {
return;
}
// replace device.expand.ports with updated record
const portIdx = device.expand.ports.findIndex((port) => port.id === e.record.id);
$devices[device.id].expand.ports[portIdx] = e.record;
}
});
});
let devicesWithoutGroups = [];
let devicesWithGroup = {};
devices.subscribe((d) => {
// sort devices into their groups
devicesWithoutGroups = [];
devicesWithGroup = {};
Object.values(d).forEach((device) => {
if (device.groups.length === 0) {
devicesWithoutGroups = [...devicesWithoutGroups, device];
return;
}
device.expand.groups.forEach((grp) => {
if (!devicesWithGroup.hasOwnProperty(grp.name)) {
devicesWithGroup[grp.name] = [];
}
devicesWithGroup[grp.name] = [...devicesWithGroup[grp.name], device];
});
});
});
// update device date
let now = Date.now();
let interval;
$: {
clearInterval(interval);
interval = setInterval(() => {
now = Date.now();
}, 1000);
}
</script>
<div class="container text-body-emphasis mb-4">
{#if Object.keys($devices).length > 0}
{#if devicesWithoutGroups.length > 0}
<div class="row">
{#each devicesWithoutGroups.sort(sortDevices) as device}
<DeviceCard {device} {now} />
{/each}
</div>
{/if}
{#each Object.keys(devicesWithGroup).sort(sortGroups) as grp}
<h2 class="mt-4 mb-0">{grp}</h2>
<div class="row">
{#each devicesWithGroup[grp] as device}
<DeviceCard {device} {now} />
{/each}
</div>
{/each}
{:else}
<div class="text-center">
<h4 class="text-muted">No devices</h4>
<p>
<a class="text-muted" href="/settings">Go to settings to add devices...</a>
</p>
</div>
{/if}
</div>

View File

@@ -1,4 +0,0 @@
/** @type {import('./$types').PageLoad} */
export function load({ params }) {
return { params };
}

View File

@@ -1,22 +0,0 @@
<script>
import { devices } from '@stores/pocketbase';
import DeviceForm from '@components/DeviceForm.svelte';
import UnauthorizedMsg from '@components/UnauthorizedMsg.svelte';
export let data;
let device;
$: {
device = $devices[data.params.id];
if (device && !device?.expand?.ports) {
device.expand.ports = [];
}
}
</script>
{#if device}
<div class="container">
<UnauthorizedMsg />
<DeviceForm bind:device mode="edit" />
</div>
{/if}

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pocketbase } from '$lib/stores/pocketbase';
import Fa from 'svelte-fa';
import { faLockOpen, faEye } from '@fortawesome/free-solid-svg-icons';
import { toggleVisibility } from '$lib/helpers/forms';
let inputPassword: HTMLInputElement;
let form = {
email: '',
password: ''
};
let errorMsg = '';
function tryAdminThenUser() {
errorMsg = '';
$pocketbase.admins
.authWithPassword(form.email, form.password)
.then(() => {
goto('/');
})
.catch(() => {
$pocketbase
.collection('users')
.authWithPassword(form.email, form.password)
.then(() => {
goto('/');
})
.catch((err) => {
errorMsg = err;
});
});
}
</script>
<div class="flex h-screen">
<div class="m-auto">
<div class="flex flex-col gap-16 w-screen max-w-lg my-4">
<div class="card bg-base-300 shadow-xl">
<div class="card-body">
<div class="flex flex-row gap-4">
<figure class="w-16"><img src="/gopher.svg" alt="Gopher" /></figure>
<h2 class="card-title">Welcome</h2>
</div>
<form class="form-control w-full" on:submit|preventDefault={tryAdminThenUser}>
<label class="label" for="email">
<span class="label-text">Email or Username:</span>
</label>
<input
id="email"
type="text"
class="input input-bordered w-full"
bind:value={form.email}
/>
<label class="label" for="password">
<span class="label-text">Password:</span>
</label>
<label class="relative block">
<div
class="absolute top-1/2 -translate-y-1/2 right-4 cursor-pointer"
role="none"
on:click={() => toggleVisibility(inputPassword)}
on:keydown={() => toggleVisibility(inputPassword)}
>
<Fa icon={faEye} />
</div>
<input
id="password"
type="password"
class="input input-bordered w-full"
maxlength="72"
bind:value={form.password}
bind:this={inputPassword}
/>
</label>
{#if errorMsg !== ''}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<span>{errorMsg}</span>
</div>
{/if}
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary" type="submit">Login <Fa icon={faLockOpen} /></button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,478 +0,0 @@
<script>
import { dev } from '$app/environment';
import {
pocketbase,
settings_private,
settings_public,
devices,
groups
} from '@stores/pocketbase';
import UnauthorizedMsg from '@components/UnauthorizedMsg.svelte';
import DeviceForm from '@components/DeviceForm.svelte';
import Fa from 'svelte-fa/src/fa.svelte';
import { faPlus, faXmark } from '@fortawesome/free-solid-svg-icons';
let version = import.meta.env.UPSNAP_VERSION;
let iconFiles = [];
let iconPreview;
let timeout;
let buttons = {
settings: {
state: 'none',
error: ''
},
scan: {
state: 'none',
error: ''
},
addAllDevices: {
state: 'none',
error: ''
}
};
let newDevice = {
name: '',
ip: '',
mac: '',
netmask: '255.255.255.0',
expand: {
ports: []
},
ports: [],
link: '',
wake_cron: '',
wake_cron_enabled: false,
shutdown_cron: '',
shutdown_cron_enabled: false,
shutdown_cmd: '',
password: '',
groups: []
};
let newGroup = '';
let scannedDevices = {};
$: if (iconPreview && iconFiles.length > 0) {
iconPreview.src = URL.createObjectURL(iconFiles[0]);
} else if (iconPreview && $settings_public.favicon !== '') {
iconPreview.src = `${dev ? $pocketbase.baseUrl : ''}/api/files/settings_public/${
$settings_public.id
}/${$settings_public.favicon}`;
} else if (iconPreview) {
iconPreview.src = '/gopher.svg';
}
async function saveSettings() {
buttons.settings.state = 'waiting';
try {
// update settings private
if ($settings_private.interval === '') {
$settings_private.interval = '@every 3s';
}
await $pocketbase
.collection('settings_private')
.update($settings_private.id, $settings_private);
// update settings public
if (iconFiles.length > 0) {
const formData = new FormData();
formData.append('favicon', iconFiles[0]);
await $pocketbase
.collection('settings_public')
.update($settings_public.id, formData);
}
await $pocketbase
.collection('settings_public')
.update($settings_public.id, $settings_public);
//set button
clearTimeout(timeout);
timeout = setTimeout(() => {
buttons.settings.state = 'none';
}, 3000);
buttons.settings.state = 'success';
} catch (error) {
clearTimeout(timeout);
timeout = setTimeout(() => {
buttons.settings.error = '';
buttons.settings.state = 'none';
}, 3000);
buttons.settings.error = error;
buttons.settings.state = 'failed';
}
}
async function scanDevices() {
buttons.scan.state = 'waiting';
await $pocketbase
.collection('settings_private')
.update($settings_private.id, $settings_private)
.catch((err) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
buttons.scan.error = '';
buttons.scan.state = 'none';
}, 3000);
buttons.scan.error = err;
buttons.scan.state = 'failed';
});
fetch(`${dev ? 'http://localhost:8090' : ''}/api/upsnap/scan`, {
headers: {
Authorization: $pocketbase.authStore.baseToken
}
})
.then((res) => res.json())
.then((data) => {
if (data.message) {
clearTimeout(timeout);
timeout = setTimeout(() => {
buttons.scan.error = '';
buttons.scan.state = 'none';
}, 3000);
buttons.scan.error = data.message;
buttons.scan.state = 'failed';
} else {
scannedDevices = data;
clearTimeout(timeout);
timeout = setTimeout(() => {
buttons.scan.state = 'none';
}, 3000);
buttons.scan.state = 'success';
}
})
.catch((error) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
buttons.scan.error = '';
buttons.scan.state = 'none';
}, 3000);
buttons.scan.error = error;
buttons.scan.state = 'failed';
});
}
async function addAllDevices() {
buttons.addAllDevices.state = 'waiting';
scannedDevices.devices.forEach(async (device) => {
device.netmask = scannedDevices.netmask;
await addDevice(device);
});
buttons.addAllDevices.state = 'success';
}
async function addDevice(device) {
await $pocketbase.collection('devices').create(device);
}
async function addGroup() {
const res = await $pocketbase.collection('groups').create({
name: newGroup
});
$groups = [...$groups, res];
newGroup = '';
}
async function deleteGroup(id) {
await $pocketbase.collection('groups').delete(id);
const ids = $groups.map((grp) => grp.id);
const i = ids.indexOf(id);
if (i > -1) {
$groups.splice(i, 1);
$groups = $groups;
}
}
</script>
<div class="container">
<UnauthorizedMsg />
<section class="m-0 mt-4 p-4 shadow-sm">
<form on:submit|preventDefault={saveSettings}>
<div class="row">
<div class="col-md-6">
<h3 class="mb-3 text-body-emphasis">Ping interval</h3>
<p>Sets the interval in which the devices are pinged.</p>
<div class="input-group mb-3">
<span class="input-group-text">Cron</span>
<input
class="form-control"
placeholder="e.g. '@every 5s' or '@every 1m'"
aria-label="Interval"
aria-describedby="addon-wrapping"
type="text"
bind:value={$settings_private.interval}
/>
</div>
<h3 class="my-3 text-body-emphasis">Title</h3>
<p>Sets the title of the website and in the browser tab.</p>
<div class="input-group mb-3">
<span class="input-group-text" id="website-title">Title</span>
<input
type="text"
class="form-control"
placeholder="e.g. 'UpSnap'"
aria-label="Website title"
aria-describedby="website-title"
bind:value={$settings_public.website_title}
/>
</div>
<h3 class="my-3 text-body-emphasis">Icon</h3>
<img
src=""
alt=""
class="img-fluid icon-preview mb-3"
bind:this={iconPreview}
/>
<div class="mb-3">
<label for="iconInput" class="form-label">Set a custom favicon.</label>
<div class="input-group">
<input
class="form-control"
type="file"
id="iconInput"
accept=".ico,.png,.svg,.gif,.jpg,.jpeg"
bind:files={iconFiles}
/>
<button
type="button"
class="btn btn-outline-danger"
on:click={() => {
iconFiles = [];
$settings_public.favicon = '';
}}>Back to Gopher</button
>
</div>
</div>
</div>
<div class="col-md-6">
<div class="callout callout-info m-0">
<h5>Ping interval:</h5>
<p>
Leave blank to use default value of <span class="fw-bold"
>"@every 3s"</span
>. Learn more about the correct syntax for cron on
<a
href="https://en.wikipedia.org/wiki/Cron"
target="_blank"
rel="noreferrer">wikipedia</a
>
or refer to the
<a
target="_blank"
rel="noreferrer"
href="https://pkg.go.dev/github.com/robfig/cron"
>package documentation</a
>
</p>
<h5>Icon:</h5>
<p class="m-0">
Supported file types are
<span class="badge text-bg-secondary">.ico</span>,
<span class="badge text-bg-secondary">.png</span>,
<span class="badge text-bg-secondary">.svg</span>,
<span class="badge text-bg-secondary">.gif</span> and
<span class="badge text-bg-secondary">.jpg/.jpeg</span>.
</p>
</div>
</div>
</div>
<button
type="submit"
class="btn btn-secondary"
class:btn-success={buttons.settings.state === 'success' ? true : false}
class:btn-warning={buttons.settings.state === 'waiting' ? true : false}
class:btn-danger={buttons.settings.state === 'failed' ? true : false}
disabled={buttons.settings.state !== 'none' ? true : false}
>
{#if buttons.settings.state === 'none'}
Save
{:else if buttons.settings.state === 'success'}
Saved
{:else if buttons.settings.state === 'waiting'}
Waiting
{:else if buttons.settings.state === 'failed'}
Failed: {buttons.settings.error}
{/if}
</button>
</form>
</section>
<DeviceForm bind:device={newDevice} mode="add" />
<section class="m-0 my-4 p-4 shadow-sm">
<div class="row">
<div class="col-md-6">
<h3 class="mb-3 text-body-emphasis">Manage Groups</h3>
<p>
{#each $groups as grp}
<div class="badge rounded-pill fs-6 text-bg-secondary me-2">
{grp.name}
<span
class="px-1"
on:click={() => deleteGroup(grp.id)}
on:keydown={() => deleteGroup(grp.id)}
role="button"
tabindex="0"
>
<Fa icon={faXmark} />
</span>
</div>
{/each}
</p>
<div class="input-group mb-3">
<span class="input-group-text">Add new group</span>
<input
class="form-control"
placeholder="Name for your new group"
aria-label="New Group"
type="text"
bind:value={newGroup}
/>
<button
class="btn btn-outline-secondary"
type="button"
on:click={() => addGroup()}
on:keydown={() => addGroup()}
>
<Fa icon={faPlus} />
</button>
</div>
</div>
</div>
</section>
<section class="m-0 my-4 p-4 shadow-sm">
<div class="row">
<div class="col-md-6">
<h3 class="mb-3 text-body-emphasis">Network scan</h3>
<p>Set the network address to scan.</p>
<form on:submit|preventDefault={scanDevices}>
<div class="input-group mb-3">
<span class="input-group-text" id="ip-range">IP range</span>
<input
type="text"
class="form-control"
placeholder="e.g. '192.168.1.0/24'"
aria-label="ip-range"
aria-describedby="ip-range"
bind:value={$settings_private.scan_range}
/>
</div>
<button
type="submit"
class="btn btn-secondary"
class:btn-success={buttons.scan.state === 'success' ? true : false}
class:btn-warning={buttons.scan.state === 'waiting' ? true : false}
class:btn-danger={buttons.scan.state === 'failed' ? true : false}
disabled={$settings_private.scan_range === '' ||
buttons.scan.state !== 'none'
? true
: false}
>
{#if buttons.scan.state === 'none'}
Scan
{:else if buttons.scan.state === 'success'}
Scanned
{:else if buttons.scan.state === 'waiting'}
Scan running (might take some seconds)
{:else if buttons.scan.state === 'failed'}
Failed: {buttons.scan.error}
{/if}
</button>
</form>
</div>
<div class="col-md-6">
<div class="callout callout-info mb-0">
<p class="m-0">
For network scan to work, you need to run UpSnap as root/admin and have nmap
installed and available in your $PATH. (For docker users, thats already the
case and you don't need to do anything.)
</p>
</div>
</div>
</div>
{#if scannedDevices.devices}
<div class="mt-3">
<div class="row align-items-center my-1">
<div class="col-lg-3 fw-bold">Name</div>
<div class="col-lg-2 fw-bold">IP</div>
<div class="col-lg-2 fw-bold">MAC</div>
<div class="col-lg-2 fw-bold">MAC Vendor</div>
<div class="col-lg-2 fw-bold">Netmask</div>
<div class="col-lg-1 fw-bold">Add</div>
</div>
{#each scannedDevices?.devices as device}
<hr class="my-1" />
<div class="row align-items-center my-1">
<div class="col-lg-3">
{device.name}
</div>
<div class="col-lg-2">
{device.ip}
</div>
<div class="col-lg-2">
{device.mac}
</div>
<div class="col-lg-2">
{device.mac_vendor}
</div>
<div class="col-lg-2">
{scannedDevices.netmask}
</div>
<div class="col-lg-1">
<button
class="btn btn-sm btn-secondary py-0"
on:click={async (e) => {
e.target.disabled = true;
device.netmask = scannedDevices.netmask;
await addDevice(device);
}}
on:keydown={async (e) => {
e.target.disabled = true;
device.netmask = scannedDevices.netmask;
await addDevice(device);
}}
>
Add <Fa icon={faPlus} />
</button>
</div>
</div>
{/each}
</div>
<button
class="btn btn-secondary mt-3"
class:btn-success={buttons.addAllDevices.state === 'success' ? true : false}
class:btn-warning={buttons.addAllDevices.state === 'waiting' ? true : false}
on:click={async (e) => {
e.target.disabled = true;
await addAllDevices();
}}
on:keydown={async (e) => {
e.target.disabled = true;
await addAllDevices();
}}
>
{#if buttons.addAllDevices.state === 'success'}
Added
{:else if buttons.addAllDevices.state === 'waiting'}
Adding all...
{:else}
Add all <Fa icon={faPlus} />
{/if}
</button>
{/if}
</section>
<p class="m-0 my-4 p-4 text-center text-muted">
{#if version !== undefined}
UpSnap version: <a
href="https://github.com/seriousm4x/UpSnap/releases/tag/{version}"
class="text-reset">{version}</a
>
{:else}
UpSnap version: (untracked)
{/if}
</p>
</div>
<style lang="scss">
.icon-preview {
height: 130px;
}
</style>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pocketbase } from '$lib/stores/pocketbase';
import { settingsPub } from '$lib/stores/settings';
import { toggleVisibility } from '$lib/helpers/forms';
import Fa from 'svelte-fa';
import { faArrowRight, faEye } from '@fortawesome/free-solid-svg-icons';
let stepsCompleted = 0;
let inputPassword: HTMLInputElement;
let inputConfirm: HTMLInputElement;
let form = {
email: '',
password: '',
confirm: ''
};
let errorMsg = '';
async function register() {
errorMsg = '';
$pocketbase.admins
.create({
email: form.email,
password: form.password,
passwordConfirm: form.confirm
})
.then(() => {
$pocketbase.admins
.authWithPassword(form.email, form.password)
.then(() => {
stepsCompleted = 2;
})
.catch((err) => {
if (err.data?.data?.identity?.message) {
errorMsg = err.data.data.identity.message;
return;
}
});
})
.catch((err) => {
if (err.data?.data?.passwordConfirm?.message) {
errorMsg = "Passwords don't match.";
} else if (err.data?.data?.email?.message) {
errorMsg = err.data.data.email.message;
} else {
errorMsg = err;
}
});
}
</script>
<div class="flex h-screen">
<div class="m-auto">
<div class="flex flex-col gap-16 w-screen max-w-lg my-4">
<div class="card bg-base-300 shadow-xl">
{#if $settingsPub?.setup_completed}
<figure class="w-72 mx-auto pt-6"><img src="/gopher.svg" alt="Gopher" /></figure>
<div class="card-body">
<h2 class="card-title">I didn't expect you here! 🧐</h2>
<p>You are already done with the setup! Nothing to do.</p>
<div class="card-actions justify-end">
<button class="btn btn-primary" on:click={() => goto('/')}>Take me back</button>
</div>
</div>
{:else if stepsCompleted === 0}
<figure class="w-72 mx-auto pt-6"><img src="/gopher.svg" alt="Gopher" /></figure>
<div class="card-body">
<h2 class="card-title">Welcome to UpSnap 🥳</h2>
<p>Please complete the following steps to finish the setup.</p>
<div class="card-actions justify-end">
<button class="btn btn-primary" on:click={() => (stepsCompleted = 1)}
>Next <Fa icon={faArrowRight} /></button
>
</div>
</div>
{:else if stepsCompleted === 1}
<div class="card-body">
<div class="flex flex-row gap-4">
<figure class="w-16"><img src="/gopher.svg" alt="Gopher" /></figure>
<h2 class="card-title">Create an admin account</h2>
</div>
<form class="form-control w-full" on:submit|preventDefault={register}>
<label class="label" for="email">
<span class="label-text">Email:</span>
</label>
<input
id="email"
type="email"
class="input input-bordered w-full"
bind:value={form.email}
/>
<label class="label" for="password">
<span class="label-text">Password:</span>
<span class="label-text-alt">min. 10 characters</span>
</label>
<label class="relative block">
<div
class="absolute top-1/2 -translate-y-1/2 right-4 cursor-pointer"
role="none"
on:click={() => toggleVisibility(inputPassword)}
on:keydown={() => toggleVisibility(inputPassword)}
>
<Fa icon={faEye} />
</div>
<input
id="password"
type="password"
class="input input-bordered w-full"
minlength="10"
maxlength="72"
bind:value={form.password}
bind:this={inputPassword}
/>
</label>
<label class="label" for="passwordConfirm">
<span class="label-text">Password confirm:</span>
</label>
<label class="relative block">
<div
class="absolute top-1/2 -translate-y-1/2 right-4 cursor-pointer"
role="none"
on:click={() => toggleVisibility(inputConfirm)}
on:keydown={() => toggleVisibility(inputConfirm)}
>
<Fa icon={faEye} />
</div>
<input
id="confirm"
type="password"
class="input input-bordered w-full"
minlength="10"
maxlength="72"
bind:value={form.confirm}
bind:this={inputConfirm}
/>
</label>
{#if errorMsg !== ''}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<span>{errorMsg}</span>
</div>
{/if}
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary" type="submit"
>Create <Fa icon={faArrowRight} /></button
>
</div>
</form>
</div>
{:else if stepsCompleted === 2}
<figure class="w-72 mx-auto pt-6"><img src="/gopher.svg" alt="Gopher" /></figure>
<div class="card-body">
<h2 class="card-title">You are all set! 🎉</h2>
<p>Go ahead and add some devices to your dashboard.</p>
<div class="card-actions justify-end">
<button class="btn btn-success" on:click={() => goto('/')}>Lets go!</button>
</div>
</div>
{/if}
</div>
{#if !$settingsPub?.setup_completed}
<ul class="steps steps-horizontal">
<li class="step step-primary">Welcome</li>
<li class="step" class:step-primary={stepsCompleted > 0}>Create account</li>
<li class="step" class:step-primary={stepsCompleted > 1}>Done</li>
</ul>
{/if}
</div>
</div>
</div>

View File

@@ -1,162 +0,0 @@
// import bootstrap functions and variables
@import '../../node_modules/bootstrap/scss/functions';
@import '../../node_modules/bootstrap/scss/variables';
@import '../../node_modules/bootstrap/scss/variables-dark';
// custom colors
$gray-1000: #111;
$grays: (
'100': $gray-100,
'200': $gray-200,
'300': $gray-300,
'400': $gray-400,
'500': $gray-500,
'600': $gray-600,
'700': $gray-700,
'800': $gray-800,
'900': $gray-900,
'1000': $gray-1000
) !default;
$body-bg-dark: $gray-1000;
$dropdown-link-active-bg: $gray-1000;
$dropdown-min-width: 0;
$form-check-input-checked-bg-color: $orange-300;
$form-check-input-checked-border-color: $orange-500;
$input-focus-border-color: $gray-500;
$input-focus-box-shadow: $gray-900;
// import rest
@import '../../node_modules/bootstrap/scss/maps';
@import '../../node_modules/bootstrap/scss/mixins';
@import '../../node_modules/bootstrap/scss/root';
@import '../../node_modules/bootstrap/scss/utilities';
@import '../../node_modules/bootstrap/scss/reboot';
@import '../../node_modules/bootstrap/scss/type';
@import '../../node_modules/bootstrap/scss/images';
@import '../../node_modules/bootstrap/scss/containers';
@import '../../node_modules/bootstrap/scss/grid';
@import '../../node_modules/bootstrap/scss/helpers';
@import '../../node_modules/bootstrap/scss/card';
@import '../../node_modules/bootstrap/scss/nav';
@import '../../node_modules/bootstrap/scss/navbar';
@import '../../node_modules/bootstrap/scss/forms';
@import '../../node_modules/bootstrap/scss/dropdown';
@import '../../node_modules/bootstrap/scss/buttons';
@import '../../node_modules/bootstrap/scss/spinners';
@import '../../node_modules/bootstrap/scss/badge';
@import '../../node_modules/bootstrap/scss/transitions';
@import '../../node_modules/bootstrap/scss/utilities/api';
section {
padding: 1rem;
margin: 1rem 0;
border-radius: 1rem;
}
.nav-link {
padding: 0.3rem 0.6rem !important;
border-radius: 0.4rem;
&.active {
background-color: $gray-300;
}
}
.navbar-toggler {
&:focus {
box-shadow: none !important;
}
}
.btn {
border-radius: 0.5rem;
}
.text-success,
.port-up {
color: $teal-500 !important;
}
.text-warning {
color: $yellow !important;
}
.text-danger,
.port-down {
color: #ef476f !important;
}
.callout {
padding: 1rem;
border-radius: 0.3rem;
margin: 1rem 0;
&.callout-info {
background-color: #1177b21f;
border-left: 0.5rem solid #1177b21f;
}
&.callout-danger {
background-color: #ef476f1f;
border-left: 0.5rem solid #ef476f1f;
}
}
.dropdown-item {
border-radius: 0.5rem;
}
.power-hover {
&:hover {
filter: drop-shadow(0px 0px 10px rgb(155, 155, 155));
transition: all 0.1s;
}
}
.ellipsis {
width: 1rem;
display: block;
}
html[data-bs-theme='light'] {
html,
body {
background: #f0f1f2;
}
section,
.card,
.login {
background: #ffffff;
}
}
html[data-bs-theme='dark'] {
html,
body {
background: #131316;
}
section,
.card,
.login {
background: #25252b;
}
.nav-link,
.dropdown-menu {
&.active {
background-color: $gray-800;
}
}
.dropdown-menu {
background-color: $gray-800;
}
}
.cursor-pointer {
cursor: pointer;
}

View File

@@ -1,17 +0,0 @@
export function sortDevices(a, b) {
return a.name.localeCompare(b.name, undefined, {
numeric: true,
sensitivity: 'base'
});
}
export function sortGroups(a, b) {
return a.localeCompare(b, undefined, {
numeric: true,
sensitivity: 'base'
});
}
export function sortPorts(a, b) {
return a.number - b.number;
}

View File

@@ -1,23 +0,0 @@
import { writable } from 'svelte/store';
import PocketBase from 'pocketbase';
// set backend url based on environment
let backend_url = import.meta.env.DEV ? 'http://127.0.0.1:8090' : '/';
// get default values for stores
const pb = new PocketBase(backend_url);
pb.autoCancellation(false);
// export stores
export const pocketbase = writable(pb);
export const settings_private = writable({
interal: '',
scan_range: ''
});
export const settings_public = writable({
website_title: '',
favicon: ''
});
export const devices = writable({});
export const groups = writable([]);
export const authorizedStore = writable(false);

View File

@@ -1,5 +0,0 @@
import { writable } from 'svelte/store';
export const theme = writable();
if (typeof window !== 'undefined')
[theme.set(localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto')];

View File

@@ -1,25 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import preprocess from 'svelte-preprocess';
import path from 'path';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: preprocess(),
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html'
}),
alias: {
'@components': path.resolve('./src/components'),
'@stores': path.resolve('./src/stores')
},
prerender: {
entries: ['/device/[id]', '/settings', '/']
}
}
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [require('daisyui')],
daisyui: {}
};

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@@ -2,6 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
envPrefix: 'UPSNAP'
plugins: [sveltekit()]
});