🔐 Removes auth from frontend

This commit is contained in:
Rune Harlyk
2024-10-29 20:58:48 +01:00
parent 1c6b9f79c5
commit 84633e5707
22 changed files with 4225 additions and 4282 deletions
+2528 -2059
View File
File diff suppressed because it is too large Load Diff
+56 -57
View File
@@ -1,81 +1,80 @@
import { user } from '$lib/stores/user';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { Err, Ok, type Result } from './utilities'; import { Err, Ok, type Result } from './utilities';
import { location } from './stores'; import { location } from './stores';
export namespace api { export namespace api {
export function get<TResponse>(endpoint: string, params?: RequestInit) { export function get<TResponse>(endpoint: string, params?: RequestInit) {
return sendRequest<TResponse>(endpoint, 'GET', null, params); return sendRequest<TResponse>(endpoint, 'GET', null, params);
} }
export function post<TResponse>(endpoint: string, data?: unknown) { export function post<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'POST', data); return sendRequest<TResponse>(endpoint, 'POST', data);
} }
export function put<TResponse>(endpoint: string, data?: unknown) { export function put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'PUT', data); return sendRequest<TResponse>(endpoint, 'PUT', data);
} }
export function remove<TResponse>(endpoint: string) { export function remove<TResponse>(endpoint: string) {
return sendRequest<TResponse>(endpoint, 'DELETE'); return sendRequest<TResponse>(endpoint, 'DELETE');
} }
} }
async function sendRequest<TResponse>( async function sendRequest<TResponse>(
endpoint: string, endpoint: string,
method: string, method: string,
data?: unknown, data?: unknown,
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint); endpoint = resolveUrl(endpoint);
const user_token = get(user).bearer_token; const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
const request = { const request = {
...params, ...params,
method, method,
body, body,
headers: { headers: {
...params?.headers, ...params?.headers,
Authorization: user_token ? 'Bearer ' + user_token : 'Basic', Authorization: 'Basic',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}; };
let response; let response;
try { try {
response = await fetch(endpoint, request); response = await fetch(endpoint, request);
} catch (error) { } catch (error) {
return Err.new(new Error(), 'An error has occurred'); return Err.new(new Error(), 'An error has occurred');
} }
const isResponseOk = response.status >= 200 && response.status < 400; const isResponseOk = response.status >= 200 && response.status < 400;
if (!isResponseOk) { if (!isResponseOk) {
if (response.status === 401) { if (response.status === 401) {
return Err.new(new ApiError(response), 'User was not authorized'); return Err.new(new ApiError(response), 'User was not authorized');
} }
return Err.new(new ApiError(response), 'An error has occurred'); return Err.new(new ApiError(response), 'An error has occurred');
} }
const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type'); const contentType =
if (contentType && contentType.includes('application/json')) { response.headers.get('Content-Type') ?? response.headers.get('Content-Type');
const data = await response.json(); if (contentType && contentType.includes('application/json')) {
return Ok.new(data as TResponse); const data = await response.json();
} else { return Ok.new(data as TResponse);
// Handle empty object as response } else {
return Ok.new(null as TResponse); // Handle empty object as response
} return Ok.new(null as TResponse);
}
} }
function resolveUrl(url: string): string { function resolveUrl(url: string): string {
if (url.startsWith('http') || !get(location)) return url; if (url.startsWith('http') || !get(location)) return url;
const protocol = window.location.protocol; const protocol = window.location.protocol;
return `${protocol}//${get(location)}${url.startsWith('/') ? '' : '/'}${url}`; return `${protocol}//${get(location)}${url.startsWith('/') ? '' : '/'}${url}`;
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor(public readonly response: Response) { constructor(public readonly response: Response) {
super(`${response.status}`); super(`${response.status}`);
} }
} }
+10 -10
View File
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { user, location } from '$lib/stores'; import { location } from '$lib/stores';
let source = `${$location}/api/camera/stream?access_token=${$user.bearer_token}`; let source = `${$location}/api/camera/stream`;
onDestroy(() => (source = '#')); onDestroy(() => (source = '#'));
</script> </script>
<div class="w-full h-full"> <div class="w-full h-full">
<img <img
src={source} src={source}
class="absolute object-cover blur-3xl w-full h-full -z-10" class="absolute object-cover blur-3xl w-full h-full -z-10"
alt="Live stream is down" alt="Live stream is down"
/> />
<img src={source} class="object-contain w-full h-full" alt="Live stream is down" /> <img src={source} class="object-contain w-full h-full" alt="Live stream is down" />
</div> </div>
-111
View File
@@ -1,111 +0,0 @@
<script lang="ts">
import logo from '$lib/assets/logo512.png';
import { PasswordInput } from '$lib/components/input';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import { fade, fly } from 'svelte/transition';
import { api } from '$lib/api';
import type { JWT } from '$lib/types/models';
import { Login } from './icons';
type SignInData = {
password: string;
username: string;
};
let username = '';
let password = '';
let loginFailed = false;
let token = { access_token: '' };
async function signInUser(data: SignInData) {
const result = await api.post<JWT>('/api/signIn', data);
if (result.isErr()) {
username = '';
password = '';
notifications.error('Wrong Username or Password!', 5000);
loginFailed = true;
setTimeout(() => {
loginFailed = false;
}, 1500);
return;
}
token = result.inner;
user.init(token.access_token);
username = $user.username;
notifications.success('User ' + username + ' signed in', 5000);
}
</script>
<div class="hero from-primary/30 to-secondary/30 min-h-screen bg-gradient-to-br">
<div
class="card lg:card-side bg-base-100 face shadow-2xl {loginFailed
? 'failure border-error border-2'
: ''}"
in:fly={{ delay: 200, y: 100, duration: 500 }}
out:fade={{ duration: 200 }}
>
<figure class="bg-base-200"><img src={logo} alt="Logo" class="h-auto w-48 lg:w-64" /></figure>
<div class="card-body w-80">
<h2 class="card-title text-2xl">Login</h2>
<form class="form-control w-full max-w-xs">
<label class="label" for="user">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
class="input input-bordered w-full max-w-xs"
id="user"
bind:value={username}
/>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<PasswordInput id="pwd" bind:value={password} />
<div class="card-actions mt-4 justify-end">
<button
class="btn btn-primary inline-flex items-center"
on:click={() => signInUser({ username, password })}
>
<Login class="mr-2 h-5 w-5" /><span>Login</span>
</button>
</div>
</form>
</div>
</div>
</div>
<style>
.failure {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
}
@keyframes shake {
10%,
90% {
transform: translatex(-1px);
}
20%,
80% {
transform: translatex(2px);
}
30%,
50%,
70% {
transform: translatex(-4px);
}
40%,
60% {
transform: translatex(4px);
}
}
</style>
+171 -183
View File
@@ -1,201 +1,189 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { user } from '$lib/stores/user'; import { createEventDispatcher } from 'svelte';
import { createEventDispatcher } from 'svelte'; import { useFeatureFlags } from '$lib/stores/featureFlags';
import { useFeatureFlags } from '$lib/stores/featureFlags'; import GithubButton from '../menu/GithubButton.svelte';
import UserButton from '../menu/UserButton.svelte'; import LogoButton from '../menu/LogoButton.svelte';
import GithubButton from '../menu/GithubButton.svelte'; import MenuList from '../menu/MenuList.svelte';
import LogoButton from '../menu/LogoButton.svelte'; import {
import MenuList from '../menu/MenuList.svelte'; Connection,
import { Settings,
Connection, MdiController,
Users, Devices,
Settings, Camera,
MdiController, Rotate3d,
Devices, MotorOutline,
Camera, Health,
Rotate3d, Folder,
MotorOutline, Update,
Health, WiFi,
Folder, Router,
Update, AP,
WiFi, Remote,
Router, Copyright,
AP, NTP,
Remote, Metrics
Copyright, } from '$lib/components/icons';
NTP, import appEnv from 'app-env';
Metrics
} from '$lib/components/icons';
import appEnv from 'app-env';
const features = useFeatureFlags(); const features = useFeatureFlags();
const appName = $page.data.app_name; const appName = $page.data.app_name;
const copyright = $page.data.copyright; const copyright = $page.data.copyright;
const github = { href: 'https://github.com/' + $page.data.github, active: true }; const github = { href: 'https://github.com/' + $page.data.github, active: true };
type menuItem = { type menuItem = {
title: string; title: string;
icon: ConstructorOfATypedSvelteComponent; icon: ConstructorOfATypedSvelteComponent;
href?: string; href?: string;
feature: boolean; feature: boolean;
active?: boolean; active?: boolean;
submenu?: menuItem[]; submenu?: menuItem[];
}; };
$: menuItems = [ $: menuItems = [
{ {
title: 'Connection', title: 'Connection',
icon: WiFi, icon: WiFi,
href: '/connection', href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME feature: !appEnv.VITE_USE_HOST_NAME
}, },
{ {
title: 'Controller', title: 'Controller',
icon: MdiController, icon: MdiController,
href: '/controller', href: '/controller',
feature: true feature: true
}, },
{ {
title: 'Peripherals', title: 'Peripherals',
icon: Devices, icon: Devices,
feature: true, feature: true,
submenu: [ submenu: [
{ {
title: 'I2C', title: 'I2C',
icon: Connection, icon: Connection,
href: '/peripherals/i2c', href: '/peripherals/i2c',
feature: true feature: true
}, },
{ {
title: 'Camera', title: 'Camera',
icon: Camera, icon: Camera,
href: '/peripherals/camera', href: '/peripherals/camera',
feature: $features.camera feature: $features.camera
}, },
{ {
title: 'Servo', title: 'Servo',
icon: MotorOutline, icon: MotorOutline,
href: '/peripherals/servo', href: '/peripherals/servo',
feature: true feature: true
}, },
{ {
title: 'IMU', title: 'IMU',
icon: Rotate3d, icon: Rotate3d,
href: '/peripherals/imu', href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp feature: $features.imu || $features.mag || $features.bmp
} }
] ]
}, },
{ {
title: 'Connections', title: 'Connections',
icon: Remote, icon: Remote,
feature: $features.ntp, feature: $features.ntp,
submenu: [ submenu: [
{ {
title: 'NTP', title: 'NTP',
icon: NTP, icon: NTP,
href: '/connections/ntp', href: '/connections/ntp',
feature: $features.ntp feature: $features.ntp
} }
] ]
}, },
{ {
title: 'WiFi', title: 'WiFi',
icon: WiFi, icon: WiFi,
feature: true, feature: true,
submenu: [ submenu: [
{ {
title: 'WiFi Station', title: 'WiFi Station',
icon: Router, icon: Router,
href: '/wifi/sta', href: '/wifi/sta',
feature: true feature: true
}, },
{ {
title: 'Access Point', title: 'Access Point',
icon: AP, icon: AP,
href: '/wifi/ap', href: '/wifi/ap',
feature: true feature: true
} }
] ]
}, },
{ {
title: 'Users', title: 'System',
icon: Users, icon: Settings,
href: '/user', feature: true,
feature: $features.security && $user.admin submenu: [
}, {
{ title: 'System Status',
title: 'System', icon: Health,
icon: Settings, href: '/system/status',
feature: true, feature: true
submenu: [ },
{ {
title: 'System Status', title: 'File System',
icon: Health, icon: Folder,
href: '/system/status', href: '/system/filesystem',
feature: true feature: true
}, },
{ {
title: 'File System', title: 'System Metrics',
icon: Folder, icon: Metrics,
href: '/system/filesystem', href: '/system/metrics',
feature: true feature: $features.analytics
}, },
{ {
title: 'System Metrics', title: 'Firmware Update',
icon: Metrics, icon: Update,
href: '/system/metrics', href: '/system/update',
feature: $features.analytics feature:
}, $features.ota || $features.upload_firmware || $features.download_firmware
{ }
title: 'Firmware Update', ]
icon: Update, }
href: '/system/update', ] as menuItem[];
feature:
($features.ota || $features.upload_firmware || $features.download_firmware) &&
(!$features.security || $user.admin)
}
]
}
] as menuItem[];
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
function setActiveMenuItem(targetTitle: string) { function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => { menuItems.forEach(item => {
item.active = item.title === targetTitle; item.active = item.title === targetTitle;
item.submenu?.forEach(subItem => { item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle; subItem.active = subItem.title === targetTitle;
}); });
}); });
menuItems = menuItems; menuItems = menuItems;
dispatch('menuClicked'); dispatch('menuClicked');
} }
$: setActiveMenuItem($page.data.title); $: setActiveMenuItem($page.data.title);
const updateMenu = (event: any) => { const updateMenu = (event: any) => {
setActiveMenuItem(event.details); setActiveMenuItem(event.details);
}; };
</script> </script>
<div class="bg-base-200 text-base-content flex h-full w-80 flex-col p-4"> <div class="bg-base-200 text-base-content flex h-full w-80 flex-col p-4">
<LogoButton {appName} /> <LogoButton {appName} />
<MenuList {menuItems} on:select{updateMenu} class="flex-grow flex-nowrap overflow-y-auto" /> <MenuList {menuItems} on:select{updateMenu} class="flex-grow flex-nowrap overflow-y-auto" />
<UserButton /> <div class="divider my-0" />
<div class="divider my-0" /> <div class="flex items-center justify-between">
<GithubButton {github} />
<div class="flex items-center justify-between"> <div class="flex items-center justify-end text-sm gap-2">
<GithubButton {github} /> <Copyright class="h-4 w-4" />{copyright}
<div class="flex items-center justify-end text-sm gap-2"> </div>
<Copyright class="h-4 w-4" />{copyright} </div>
</div>
</div>
</div> </div>
@@ -1,17 +0,0 @@
<script lang="ts">
import { user } from '$lib/stores';
import { useFeatureFlags } from "$lib/stores";
import { Avatar, Logout } from '../icons';
const features = useFeatureFlags();
</script>
{#if $features.security}
<div class="flex items-center">
<Avatar class="h-8 w-8" />
<span class="flex-grow px-4 text-xl font-bold">{$user.username}</span>
<button class="btn btn-ghost" on:click={user.invalidate}>
<Logout class="h-8 w-8 rotate-180" />
</button>
</div>
{/if}
@@ -1,113 +1,112 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { openModal, closeAllModals } from 'svelte-modals'; import { openModal, closeAllModals } from 'svelte-modals';
import { user } from '$lib/stores/user'; import { notifications } from '$lib/components/toasts/notifications';
import { notifications } from '$lib/components/toasts/notifications'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'; import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions'; import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { GithubRelease } from '$lib/types/models'; import type { GithubRelease } from '$lib/types/models';
import { useFeatureFlags } from '$lib/stores/featureFlags'; import { useFeatureFlags } from '$lib/stores/featureFlags';
import { Cancel, CloudDown, Firmware } from '../icons'; import { Cancel, CloudDown, Firmware } from '../icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
export let update = false; export let update = false;
let firmwareVersion: string; let firmwareVersion: string;
let firmwareDownloadLink: string; let firmwareDownloadLink: string;
async function getGithubAPI() { async function getGithubAPI() {
const headers = { const headers = {
accept: 'application/vnd.github+json', accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28' 'X-GitHub-Api-Version': '2022-11-28'
}; };
const result = await api.get<GithubRelease>( const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${$page.data.github}/releases/latest`, `https://api.github.com/repos/${$page.data.github}/releases/latest`,
{ headers } { headers }
); );
if (result.inner.message === '404' || result.inner.message == 'Not Found') { if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository'); console.warn('Error: Could not find releases in the repository');
return; return;
} }
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return; return;
} }
const results = result.inner; const results = result.inner;
update = false; update = false;
firmwareVersion = ''; firmwareVersion = '';
if (compareVersions(results.tag_name, $features.firmware_version) === 1) { if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
// iterate over assets and find the correct one // iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) { for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin // check if the asset is of type *.bin
if ( if (
results.assets[i].name.includes('.bin') && results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target) results.assets[i].name.includes($features.firmware_built_target)
) { ) {
update = true; update = true;
firmwareVersion = results.tag_name; firmwareVersion = results.tag_name;
firmwareDownloadLink = results.assets[i].browser_download_url; firmwareDownloadLink = results.assets[i].browser_download_url;
notifications.info('Firmware update available.', 5000); notifications.info('Firmware update available.', 5000);
} }
} }
} }
} }
async function postGithubDownload(url: string) { async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url }); const result = await api.post('/api/downloadUpdate', { download_url: url });
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return; return;
} }
} }
onMount(async () => { onMount(async () => {
if ($features.download_firmware && (!$features.security || $user.admin)) { if ($features.download_firmware) {
await getGithubAPI(); await getGithubAPI();
const interval = setInterval( const interval = setInterval(
async () => { async () => {
await getGithubAPI(); await getGithubAPI();
}, },
60 * 60 * 1000 60 * 60 * 1000
); // once per hour ); // once per hour
} }
}); });
function confirmGithubUpdate(url: string) { function confirmGithubUpdate(url: string) {
openModal(ConfirmDialog, { openModal(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device', title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?', message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown } confirm: { label: 'Update', icon: CloudDown }
}, },
onConfirm: () => { onConfirm: () => {
postGithubDownload(url); postGithubDownload(url);
openModal(GithubUpdateDialog, { openModal(GithubUpdateDialog, {
onConfirm: () => closeAllModals() onConfirm: () => closeAllModals()
}); });
} }
}); });
} }
</script> </script>
{#if update} {#if update}
<div class="indicator flex-none"> <div class="indicator flex-none">
<button <button
class="btn btn-square btn-ghost h-9 w-9" class="btn btn-square btn-ghost h-9 w-9"
on:click={() => confirmGithubUpdate(firmwareDownloadLink)} on:click={() => confirmGithubUpdate(firmwareDownloadLink)}
> >
<span <span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1" class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>{firmwareVersion}</span >{firmwareVersion}</span
> >
<Firmware class="h-7 w-7" /> <Firmware class="h-7 w-7" />
</button> </button>
</div> </div>
{/if} {/if}
-1
View File
@@ -5,6 +5,5 @@ export * from './socket';
export * from './fullscreen'; export * from './fullscreen';
export * from './telemetry'; export * from './telemetry';
export * from './analytics'; export * from './analytics';
export * from './user';
export * from './featureFlags'; export * from './featureFlags';
export * from './location-store'; export * from './location-store';
-40
View File
@@ -1,40 +0,0 @@
import { goto } from '$app/navigation';
import { jwtDecode } from 'jwt-decode';
import { persistentStore } from '$lib/utilities';
export type UserProfile = {
username: string;
admin: boolean;
bearer_token: string;
};
type DecodedJWT = Omit<UserProfile, 'bearer_token'>;
const emptyUser: UserProfile = {
username: '',
admin: false,
bearer_token: ''
};
function createUserStore() {
const store = persistentStore<UserProfile>('user', emptyUser);
return {
subscribe: store.subscribe,
init: (access_token: string) => {
const decoded: DecodedJWT = jwtDecode(access_token);
const userProfile: UserProfile = {
bearer_token: access_token,
username: decoded.username,
admin: decoded.admin
};
store.set(userProfile);
},
invalidate: () => {
store.set(emptyUser);
goto('/');
}
};
}
export const user = createUserStore();
+123 -126
View File
@@ -1,201 +1,198 @@
export type vector = { x: number; y: number }; export type vector = { x: number; y: number };
export interface ControllerInput { export interface ControllerInput {
left: vector; left: vector;
right: vector; right: vector;
height: number; height: number;
speed: number; speed: number;
s1: number; s1: number;
} }
export type GithubRelease = { export type GithubRelease = {
message: string; message: string;
tag_name: string; tag_name: string;
assets: Array<{ assets: Array<{
name: string; name: string;
browser_download_url: string; browser_download_url: string;
}>; }>;
}; };
export type JWT = { access_token: string };
export type angles = number[] | Int16Array; export type angles = number[] | Int16Array;
export type WifiStatus = { export type WifiStatus = {
status: number; status: number;
local_ip: string; local_ip: string;
mac_address: string; mac_address: string;
rssi: number; rssi: number;
ssid: string; ssid: string;
bssid: string; bssid: string;
channel: number; channel: number;
subnet_mask: string; subnet_mask: string;
gateway_ip: string; gateway_ip: string;
dns_ip_1: string; dns_ip_1: string;
dns_ip_2?: string; dns_ip_2?: string;
}; };
export type WifiSettings = { export type WifiSettings = {
hostname: string; hostname: string;
priority_RSSI: boolean; priority_RSSI: boolean;
wifi_networks: KnownNetworkItem[]; wifi_networks: KnownNetworkItem[];
}; };
export type NetworkList = { export type NetworkList = {
networks: NetworkItem[]; networks: NetworkItem[];
}; };
export type KnownNetworkItem = { export type KnownNetworkItem = {
ssid: string; ssid: string;
password: string; password: string;
static_ip_config: boolean; static_ip_config: boolean;
local_ip?: string; local_ip?: string;
subnet_mask?: string; subnet_mask?: string;
gateway_ip?: string; gateway_ip?: string;
dns_ip_1?: string; dns_ip_1?: string;
dns_ip_2?: string; dns_ip_2?: string;
}; };
export type NetworkItem = { export type NetworkItem = {
rssi: number; rssi: number;
ssid: string; ssid: string;
bssid: string; bssid: string;
channel: number; channel: number;
encryption_type: number; encryption_type: number;
}; };
export type ApStatus = { export type ApStatus = {
status: number; status: number;
ip_address: string; ip_address: string;
mac_address: string; mac_address: string;
station_num: number; station_num: number;
}; };
export type ApSettings = { export type ApSettings = {
provision_mode: number; provision_mode: number;
ssid: string; ssid: string;
password: string; password: string;
channel: number; channel: number;
ssid_hidden: boolean; ssid_hidden: boolean;
max_clients: number; max_clients: number;
local_ip: string; local_ip: string;
gateway_ip: string; gateway_ip: string;
subnet_mask: string; subnet_mask: string;
}; };
export type NTPStatus = { export type NTPStatus = {
status: number; status: number;
utc_time: string; utc_time: string;
local_time: string; local_time: string;
server: string; server: string;
uptime: number; uptime: number;
}; };
export type Battery = { export type Battery = {
voltage: number; voltage: number;
current: number; current: number;
}; };
export type DownloadOTA = { export type DownloadOTA = {
status: string; status: string;
progress: number; progress: number;
error: string; error: string;
}; };
export type NTPSettings = { export type NTPSettings = {
enabled: boolean; enabled: boolean;
server: string; server: string;
tz_label: string; tz_label: string;
tz_format: string; tz_format: string;
}; };
export type Analytics = { export type Analytics = {
max_alloc_heap: number; max_alloc_heap: number;
psram_size: number; psram_size: number;
free_psram: number; free_psram: number;
free_heap: number; free_heap: number;
total_heap: number; total_heap: number;
min_free_heap: number; min_free_heap: number;
core_temp: number; core_temp: number;
fs_total: number; fs_total: number;
fs_used: number; fs_used: number;
uptime: number; uptime: number;
cpu0_usage: number; cpu0_usage: number;
cpu1_usage: number; cpu1_usage: number;
cpu_usage: number; cpu_usage: number;
}; };
export type Rssi = { export type Rssi = {
rssi: number; rssi: number;
ssid: string; ssid: string;
}; };
export type StaticSystemInformation = { export type StaticSystemInformation = {
esp_platform: string; esp_platform: string;
firmware_version: string; firmware_version: string;
cpu_freq_mhz: number; cpu_freq_mhz: number;
cpu_type: string; cpu_type: string;
cpu_rev: number; cpu_rev: number;
cpu_cores: number; cpu_cores: number;
sketch_size: number; sketch_size: number;
free_sketch_space: number; free_sketch_space: number;
sdk_version: string; sdk_version: string;
arduino_version: string; arduino_version: string;
flash_chip_size: number; flash_chip_size: number;
flash_chip_speed: number; flash_chip_speed: number;
cpu_reset_reason: string; cpu_reset_reason: string;
}; };
export type SystemInformation = Analytics & StaticSystemInformation; export type SystemInformation = Analytics & StaticSystemInformation;
export type IMU = { export type IMU = {
x: number; x: number;
y: number; y: number;
z: number; z: number;
imu_temp: number; imu_temp: number;
altitude: number; altitude: number;
bmp_temp: number; bmp_temp: number;
pressure: number; pressure: number;
}; };
export interface I2CDevice { export interface I2CDevice {
address: number; address: number;
part_number: string; part_number: string;
name: string; name: string;
}; }
export type CameraSettings = { export type CameraSettings = {
framesize: number; framesize: number;
quality: number; quality: number;
brightness: number; brightness: number;
contrast: number; contrast: number;
saturation: number; saturation: number;
sharpness: number; sharpness: number;
denoise: number; denoise: number;
special_effect: number; special_effect: number;
wb_mode: number; wb_mode: number;
vflip: boolean; vflip: boolean;
hmirror: boolean; hmirror: boolean;
}; };
export type File = number; export type File = number;
export interface Directory { export interface Directory {
[key: string]: File | Directory; [key: string]: File | Directory;
} }
export type Servo = { export type Servo = {
name: string; name: string;
channel: number; channel: number;
inverted: boolean; inverted: boolean;
angle: number; angle: number;
center_angle: number; center_angle: number;
}; };
export type ServoConfiguration = { export type ServoConfiguration = {
is_active: boolean; is_active: boolean;
servo_pwm_frequency: number; servo_pwm_frequency: number;
servo_oscillator_frequency: number; servo_oscillator_frequency: number;
servos: Servo[]; servos: Servo[];
}; };
+16 -36
View File
@@ -8,12 +8,9 @@
import '../app.css'; import '../app.css';
import Menu from '../lib/components/menu/Menu.svelte'; import Menu from '../lib/components/menu/Menu.svelte';
import Statusbar from '../lib/components/statusbar/statusbar.svelte'; import Statusbar from '../lib/components/statusbar/statusbar.svelte';
import Login from '../lib/components/login.svelte';
import { import {
telemetry, telemetry,
analytics, analytics,
user,
type UserProfile,
ModesEnum, ModesEnum,
kinematicData, kinematicData,
mode, mode,
@@ -21,21 +18,16 @@
servoAngles, servoAngles,
servoAnglesOut, servoAnglesOut,
socket, socket,
location location,
useFeatureFlags
} from '$lib/stores'; } from '$lib/stores';
import type { Analytics, Battery, DownloadOTA } from '$lib/types/models'; import type { Analytics, Battery, DownloadOTA } from '$lib/types/models';
import { api } from '$lib/api';
import { useFeatureFlags } from '$lib/stores/featureFlags';
const features = useFeatureFlags(); const features = useFeatureFlags();
onMount(async () => { onMount(async () => {
if ($user.bearer_token !== '') {
await validateUser($user);
}
const ws_token = $features.security ? '?access_token=' + $user.bearer_token : '';
const ws = $location ? $location : window.location.host; const ws = $location ? $location : window.location.host;
socket.init(`ws://${ws}/ws/events${ws_token}`); socket.init(`ws://${ws}/ws/events`);
addEventListeners(); addEventListeners();
@@ -75,14 +67,6 @@
socket.off('otastatus', handleOAT); socket.off('otastatus', handleOAT);
}; };
async function validateUser(userdata: UserProfile) {
const result = await api.get('/api/verifyAuthorization');
if (result.isErr()) {
user.invalidate();
console.error('Error:', result.inner);
}
}
const handleOpen = () => { const handleOpen = () => {
notifications.success('Connection to device established', 5000); notifications.success('Connection to device established', 5000);
}; };
@@ -109,25 +93,21 @@
<title>{$page.data.title}</title> <title>{$page.data.title}</title>
</svelte:head> </svelte:head>
{#if $features?.security && $user.bearer_token === ''} <div class="drawer">
<Login /> <input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
{:else} <div class="drawer-content flex flex-col">
<div class="drawer"> <!-- Status bar content here -->
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} /> <Statusbar />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
<Statusbar />
<!-- Main page content here --> <!-- Main page content here -->
<slot /> <slot />
</div>
<!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay" />
<Menu on:menuClicked={() => (menuOpen = false)} />
</div>
</div> </div>
{/if} <!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay" />
<Menu on:menuClicked={() => (menuOpen = false)} />
</div>
</div>
<Modals> <Modals>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
+15 -16
View File
@@ -1,25 +1,24 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { WiFi } from '$lib/components/icons'; import { WiFi } from '$lib/components/icons';
import { location, socket, useFeatureFlags, user } from '$lib/stores'; import { location, socket, useFeatureFlags } from '$lib/stores';
const features = useFeatureFlags(); const features = useFeatureFlags();
const update = () => { const update = () => {
const ws_token = $features.security ? '?access_token=' + $user.bearer_token : ''; const ws = $location ? $location : window.location.host;
const ws = $location ? $location : window.location.host; socket.init(`ws://${ws}/ws/events`);
socket.init(`ws://${ws}/ws/events${ws_token}`); };
};
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
<WiFi slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <WiFi slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Connection</span> <span slot="title">Connection</span>
<div class="flex"> <div class="flex">
<label class="label w-32" for="server">Address:</label> <label class="label w-32" for="server">Address:</label>
<input class="input" bind:value={$location} /> <input class="input" bind:value={$location} />
</div> </div>
<button class="btn btn-primary" on:click={update}>Update</button> <button class="btn btn-primary" on:click={update}>Update</button>
</SettingsCard> </SettingsCard>
+213 -219
View File
@@ -1,260 +1,254 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import Collapsible from '$lib/components/Collapsible.svelte'; import Collapsible from '$lib/components/Collapsible.svelte';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import { user, useFeatureFlags } from '$lib/stores'; import { useFeatureFlags } from '$lib/stores';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import { TIME_ZONES } from './timezones'; import { TIME_ZONES } from './timezones';
import type { NTPSettings, NTPStatus } from '$lib/types/models'; import type { NTPSettings, NTPStatus } from '$lib/types/models';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { NTP, UTC, Stopwatch, Clock, Server } from '$lib/components/icons'; import { NTP, UTC, Stopwatch, Clock, Server } from '$lib/components/icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
let ntpSettings: NTPSettings; let ntpSettings: NTPSettings;
let ntpStatus: NTPStatus; let ntpStatus: NTPStatus;
async function getNTPStatus() { async function getNTPStatus() {
const result = await api.get<NTPStatus>('/api/ntpStatus'); const result = await api.get<NTPStatus>('/api/ntpStatus');
if (result.isErr()){ if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return return;
} }
ntpStatus = result.inner ntpStatus = result.inner;
} }
async function getNTPSettings() { async function getNTPSettings() {
const result = await api.get<NTPSettings>('/api/ntpSettings'); const result = await api.get<NTPSettings>('/api/ntpSettings');
if (result.isErr()){ if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return return;
} }
ntpSettings = result.inner ntpSettings = result.inner;
} }
const interval = setInterval(async () => { const interval = setInterval(async () => getNTPStatus(), 5000);
getNTPStatus();
}, 5000);
onDestroy(() => clearInterval(interval)); onDestroy(() => clearInterval(interval));
onMount(() => { onMount(getNTPSettings);
if (!$features.security || $user.admin) {
getNTPSettings();
}
});
let formField: any; let formField: any;
let formErrors = { let formErrors = {
server: false server: false
}; };
async function postNTPSettings(data: NTPSettings) { async function postNTPSettings(data: NTPSettings) {
const result = await api.post<NTPSettings>('/api/ntpSettings', data); const result = await api.post<NTPSettings>('/api/ntpSettings', data);
if (result.isErr()){ if (result.isErr()) {
notifications.error('User not authorized.', 3000); notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner); console.error('Error:', result.inner);
return return;
} }
ntpSettings = result.inner ntpSettings = result.inner;
} }
function handleSubmitNTP() { function handleSubmitNTP() {
let valid = true; let valid = true;
// Validate Server // Validate Server
// RegEx for IPv4 // RegEx for IPv4
const regexExpIPv4 = const regexExpIPv4 =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/; /\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
const regexExpURL = const regexExpURL =
/[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i; /[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i;
if (!regexExpURL.test(ntpSettings.server) && !regexExpIPv4.test(ntpSettings.server)) { if (!regexExpURL.test(ntpSettings.server) && !regexExpIPv4.test(ntpSettings.server)) {
valid = false; valid = false;
formErrors.server = true; formErrors.server = true;
} else { } else {
formErrors.server = false; formErrors.server = false;
} }
ntpSettings.tz_format = TIME_ZONES[ntpSettings.tz_label]; ntpSettings.tz_format = TIME_ZONES[ntpSettings.tz_label];
// Submit JSON to REST API // Submit JSON to REST API
if (valid) { if (valid) {
postNTPSettings(ntpSettings); postNTPSettings(ntpSettings);
//alert('Form Valid'); //alert('Form Valid');
} }
} }
function convertSeconds(seconds: number) { function convertSeconds(seconds: number) {
// Calculate the number of seconds, minutes, hours, and days // Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60); let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60); let hours = Math.floor(minutes / 60);
let days = Math.floor(hours / 24); let days = Math.floor(hours / 24);
// Calculate the remaining hours, minutes, and seconds // Calculate the remaining hours, minutes, and seconds
hours = hours % 24; hours = hours % 24;
minutes = minutes % 60; minutes = minutes % 60;
seconds = seconds % 60; seconds = seconds % 60;
// Create the formatted string // Create the formatted string
let result = ''; let result = '';
if (days > 0) { if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' '; result += days + ' day' + (days > 1 ? 's' : '') + ' ';
} }
if (hours > 0) { if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '; result += hours + ' hour' + (hours > 1 ? 's' : '') + ' ';
} }
if (minutes > 0) { if (minutes > 0) {
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '; result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' ';
} }
result += seconds + ' second' + (seconds > 1 ? 's' : ''); result += seconds + ' second' + (seconds > 1 ? 's' : '');
return result; return result;
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
<Clock slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Clock slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Network Time</span> <span slot="title">Network Time</span>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getNTPStatus()} {#await getNTPStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then nothing}
<div <div
class="flex w-full flex-col space-y-1" class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div <div
class="mask mask-hexagon h-auto w-10 {ntpStatus.status === 1 class="mask mask-hexagon h-auto w-10 {ntpStatus.status === 1 ?
? 'bg-success' 'bg-success'
: 'bg-error'}" : 'bg-error'}"
> >
<NTP <NTP
class="h-auto w-full scale-75 {ntpStatus.status === 1 class="h-auto w-full scale-75 {ntpStatus.status === 1 ?
? 'text-success-content' 'text-success-content'
: 'text-error-content'}" : 'text-error-content'}"
/> />
</div> </div>
<div> <div>
<div class="font-bold">Status</div> <div class="font-bold">Status</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{ntpStatus.status === 1 ? 'Active' : 'Inactive'} {ntpStatus.status === 1 ? 'Active' : 'Inactive'}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<Server class="text-primary-content h-auto w-full scale-75" /> <Server class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">NTP Server</div> <div class="font-bold">NTP Server</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{ntpStatus.server} {ntpStatus.server}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<Clock class="text-primary-content h-auto w-full scale-75" /> <Clock class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Local Time</div> <div class="font-bold">Local Time</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', { {new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long', dateStyle: 'long',
timeStyle: 'long' timeStyle: 'long'
}).format(new Date(ntpStatus.local_time))} }).format(new Date(ntpStatus.local_time))}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<UTC class="text-primary-content h-auto w-full scale-75" /> <UTC class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">UTC Time</div> <div class="font-bold">UTC Time</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', { {new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long', dateStyle: 'long',
timeStyle: 'long', timeStyle: 'long',
timeZone: 'UTC' timeZone: 'UTC'
}).format(new Date(ntpStatus.utc_time))} }).format(new Date(ntpStatus.utc_time))}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" /> <Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Uptime</div> <div class="font-bold">Uptime</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{convertSeconds(ntpStatus.uptime)} {convertSeconds(ntpStatus.uptime)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/await} {/await}
</div> </div>
{#if !$features.security || $user.admin} <Collapsible open={false} on:closed={getNTPSettings}>
<Collapsible open={false} on:closed={getNTPSettings}> <span slot="title">Change NTP Settings</span>
<span slot="title">Change NTP Settings</span> <form
<form class="form-control w-full"
class="form-control w-full" on:submit|preventDefault={handleSubmitNTP}
on:submit|preventDefault={handleSubmitNTP} novalidate
novalidate bind:this={formField}
bind:this={formField} >
> <label class="label cursor-pointer justify-start gap-4">
<label class="label cursor-pointer justify-start gap-4"> <input
<input type="checkbox"
type="checkbox" bind:checked={ntpSettings.enabled}
bind:checked={ntpSettings.enabled} class="checkbox checkbox-primary"
class="checkbox checkbox-primary" />
/> <span class="">Enable NTP</span>
<span class="">Enable NTP</span> </label>
</label> <label class="label" for="server">
<label class="label" for="server"> <span class="label-text text-md">Server</span>
<span class="label-text text-md">Server</span> </label>
</label> <input
<input type="text"
type="text" min="3"
min="3" max="64"
max="64" class="input input-bordered invalid:border-error w-full invalid:border-2 {(
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.server formErrors.server
? 'border-error border-2' ) ?
: ''}" 'border-error border-2'
bind:value={ntpSettings.server} : ''}"
id="server" bind:value={ntpSettings.server}
required id="server"
/> required
<label class="label" for="subnet"> />
<span class="label-text-alt text-error {formErrors.server ? '' : 'hidden'}" <label class="label" for="subnet">
>Must be a valid IPv4 address or URL</span <span class="label-text-alt text-error {formErrors.server ? '' : 'hidden'}"
> >Must be a valid IPv4 address or URL</span
</label> >
<label class="label" for="tz"> </label>
<span class="label-text text-md">Pick Time Zone</span> <label class="label" for="tz">
</label> <span class="label-text text-md">Pick Time Zone</span>
<select class="select select-bordered" bind:value={ntpSettings.tz_label} id="tz"> </label>
{#each Object.entries(TIME_ZONES) as [tz_label, tz_format]} <select class="select select-bordered" bind:value={ntpSettings.tz_label} id="tz">
<option value={tz_label}>{tz_label}</option> {#each Object.entries(TIME_ZONES) as [tz_label, tz_format]}
{/each} <option value={tz_label}>{tz_label}</option>
</select> {/each}
</select>
<div class="mt-6 place-self-end"> <div class="mt-6 place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button> <button class="btn btn-primary" type="submit">Apply Settings</button>
</div> </div>
</form> </form>
</Collapsible> </Collapsible>
{/if}
</SettingsCard> </SettingsCard>
+289 -285
View File
@@ -1,319 +1,323 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { openModal, closeModal } from 'svelte-modals'; import { openModal, closeModal } from 'svelte-modals';
import { user } from '$lib/stores/user'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { page } from '$app/stores'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import { slide } from 'svelte/transition';
import Spinner from '$lib/components/Spinner.svelte'; import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import type { SystemInformation, Analytics } from '$lib/types/models'; import type { SystemInformation, Analytics } from '$lib/types/models';
import { socket } from '$lib/stores/socket'; import { socket } from '$lib/stores/socket';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { convertSeconds } from '$lib/utilities'; import { convertSeconds } from '$lib/utilities';
import { useFeatureFlags } from '$lib/stores/featureFlags'; import { useFeatureFlags } from '$lib/stores/featureFlags';
import { import {
Cancel, Cancel,
Power, Power,
FactoryReset, FactoryReset,
Sleep, Sleep,
Health, Health,
CPU, CPU,
SDK, SDK,
CPP, CPP,
Speed, Speed,
Heap, Heap,
Pyramid, Pyramid,
Sketch, Sketch,
Flash, Flash,
Folder, Folder,
Temperature, Temperature,
Stopwatch Stopwatch
} from '$lib/components/icons'; } from '$lib/components/icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
let systemInformation: SystemInformation; let systemInformation: SystemInformation;
async function getSystemStatus() { async function getSystemStatus() {
const result = await api.get<SystemInformation>('/api/systemStatus'); const result = await api.get<SystemInformation>('/api/systemStatus');
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return; return;
} }
systemInformation = result.inner; systemInformation = result.inner;
return systemInformation; return systemInformation;
} }
const postFactoryReset = async () => await api.post('/api/factoryReset'); const postFactoryReset = async () => await api.post('/api/factoryReset');
const postSleep = async () => await api.post('api/sleep'); const postSleep = async () => await api.post('api/sleep');
onMount(() => socket.on('analytics', handleSystemData)); onMount(() => socket.on('analytics', handleSystemData));
onDestroy(() => socket.off('analytics', handleSystemData)); onDestroy(() => socket.off('analytics', handleSystemData));
const handleSystemData = (data: Analytics) => const handleSystemData = (data: Analytics) =>
(systemInformation = { ...systemInformation, ...data }); (systemInformation = { ...systemInformation, ...data });
const postRestart = async () => await api.post('/api/restart'); const postRestart = async () => await api.post('/api/restart');
function confirmRestart() { function confirmRestart() {
openModal(ConfirmDialog, { openModal(ConfirmDialog, {
title: 'Confirm Restart', title: 'Confirm Restart',
message: 'Are you sure you want to restart the device?', message: 'Are you sure you want to restart the device?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Restart', icon: Power } confirm: { label: 'Restart', icon: Power }
}, },
onConfirm: () => { onConfirm: () => {
closeModal(); closeModal();
postRestart(); postRestart();
} }
}); });
} }
function confirmReset() { function confirmReset() {
openModal(ConfirmDialog, { openModal(ConfirmDialog, {
title: 'Confirm Factory Reset', title: 'Confirm Factory Reset',
message: 'Are you sure you want to reset the device to its factory defaults?', message: 'Are you sure you want to reset the device to its factory defaults?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Factory Reset', icon: FactoryReset } confirm: { label: 'Factory Reset', icon: FactoryReset }
}, },
onConfirm: () => { onConfirm: () => {
closeModal(); closeModal();
postFactoryReset(); postFactoryReset();
} }
}); });
} }
function confirmSleep() { function confirmSleep() {
openModal(ConfirmDialog, { openModal(ConfirmDialog, {
title: 'Confirm Going to Sleep', title: 'Confirm Going to Sleep',
message: 'Are you sure you want to put the device into sleep?', message: 'Are you sure you want to put the device into sleep?',
labels: { labels: {
cancel: { label: 'Abort', icon: Cancel }, cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Sleep', icon: Sleep } confirm: { label: 'Sleep', icon: Sleep }
}, },
onConfirm: () => { onConfirm: () => {
closeModal(); closeModal();
postSleep(); postSleep();
} }
}); });
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
<Health slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <Health slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">System Status</span> <span slot="title">System Status</span>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getSystemStatus()} {#await getSystemStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then nothing}
<div <div
class="flex w-full flex-col space-y-1" class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<CPU class="text-primary-content h-auto w-full scale-75" /> <CPU class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Chip</div> <div class="font-bold">Chip</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.cpu_type} Rev {systemInformation.cpu_rev} {systemInformation.cpu_type} Rev {systemInformation.cpu_rev}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<SDK class="text-primary-content h-auto w-full scale-75" /> <SDK class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">SDK Version</div> <div class="font-bold">SDK Version</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
ESP-IDF {systemInformation.sdk_version} / Arduino {systemInformation.arduino_version} ESP-IDF {systemInformation.sdk_version} / Arduino {systemInformation.arduino_version}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<CPP class="text-primary-content h-auto w-full scale-75" /> <CPP class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Firmware Version</div> <div class="font-bold">Firmware Version</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.firmware_version} {systemInformation.firmware_version}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Speed class="text-primary-content h-auto w-full scale-75" /> <Speed class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">CPU Frequency</div> <div class="font-bold">CPU Frequency</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.cpu_freq_mhz} MHz {systemInformation.cpu_cores == 2 {systemInformation.cpu_freq_mhz} MHz {systemInformation.cpu_cores == 2 ?
? 'Dual Core' 'Dual Core'
: 'Single Core'} : 'Single Core'}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Heap class="text-primary-content h-auto w-full scale-75" /> <Heap class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Heap (Free / Max Alloc)</div> <div class="font-bold">Heap (Free / Max Alloc)</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.free_heap.toLocaleString('en-US')} / {systemInformation.max_alloc_heap.toLocaleString( {systemInformation.free_heap.toLocaleString('en-US')} / {systemInformation.max_alloc_heap.toLocaleString(
'en-US' 'en-US'
)} bytes )} bytes
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Pyramid class="text-primary-content h-auto w-full scale-75" /> <Pyramid class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">PSRAM (Size / Free)</div> <div class="font-bold">PSRAM (Size / Free)</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.psram_size.toLocaleString('en-US')} / {systemInformation.psram_size.toLocaleString( {systemInformation.psram_size.toLocaleString('en-US')} / {systemInformation.psram_size.toLocaleString(
'en-US' 'en-US'
)} bytes )} bytes
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Sketch class="text-primary-content h-auto w-full scale-75" /> <Sketch class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Sketch (Used / Free)</div> <div class="font-bold">Sketch (Used / Free)</div>
<div class="flex flex-wrap justify-start gap-1 text-sm opacity-75"> <div class="flex flex-wrap justify-start gap-1 text-sm opacity-75">
<span> <span>
{( {(
(systemInformation.sketch_size / systemInformation.free_sketch_space) * (systemInformation.sketch_size /
100 systemInformation.free_sketch_space) *
).toFixed(1)} % of 100
{(systemInformation.free_sketch_space / 1000000).toLocaleString('en-US')} MB used ).toFixed(1)} % of
</span> {(systemInformation.free_sketch_space / 1000000).toLocaleString(
'en-US'
)} MB used
</span>
<span> <span>
({( ({(
(systemInformation.free_sketch_space - systemInformation.sketch_size) / (systemInformation.free_sketch_space -
1000000 systemInformation.sketch_size) /
).toLocaleString('en-US')} MB free) 1000000
</span> ).toLocaleString('en-US')} MB free)
</div> </span>
</div> </div>
</div> </div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Flash class="text-primary-content h-auto w-full scale-75" /> <Flash class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Flash Chip (Size / Speed)</div> <div class="font-bold">Flash Chip (Size / Speed)</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{(systemInformation.flash_chip_size / 1000000).toLocaleString('en-US')} MB / {( {(systemInformation.flash_chip_size / 1000000).toLocaleString('en-US')} MB
systemInformation.flash_chip_speed / 1000000 / {(systemInformation.flash_chip_speed / 1000000).toLocaleString(
).toLocaleString('en-US')} MHz 'en-US'
</div> )} MHz
</div> </div>
</div> </div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Folder class="text-primary-content h-auto w-full scale-75" /> <Folder class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">File System (Used / Total)</div> <div class="font-bold">File System (Used / Total)</div>
<div class="flex flex-wrap justify-start gap-1 text-sm opacity-75"> <div class="flex flex-wrap justify-start gap-1 text-sm opacity-75">
<span <span
>{((systemInformation.fs_used / systemInformation.fs_total) * 100).toFixed(1)} % of {( >{(
systemInformation.fs_total / 1000000 (systemInformation.fs_used / systemInformation.fs_total) *
).toLocaleString('en-US')} MB used</span 100
> ).toFixed(1)} % of {(
systemInformation.fs_total / 1000000
).toLocaleString('en-US')} MB used</span
>
<span <span
>({( >({(
(systemInformation.fs_total - systemInformation.fs_used) / (systemInformation.fs_total - systemInformation.fs_used) /
1000000 1000000
).toLocaleString('en-US')} ).toLocaleString('en-US')}
MB free)</span MB free)</span
> >
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Temperature class="text-primary-content h-auto w-full scale-75" /> <Temperature class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Core Temperature</div> <div class="font-bold">Core Temperature</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.core_temp == 53.33 {systemInformation.core_temp == 53.33 ?
? 'NaN' 'NaN'
: systemInformation.core_temp.toFixed(2) + ' °C'} : systemInformation.core_temp.toFixed(2) + ' °C'}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" /> <Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Uptime</div> <div class="font-bold">Uptime</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{convertSeconds(systemInformation.uptime)} {convertSeconds(systemInformation.uptime)}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none"> <div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Power class="text-primary-content h-auto w-full scale-75" /> <Power class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">Reset Reason</div> <div class="font-bold">Reset Reason</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{systemInformation.cpu_reset_reason} {systemInformation.cpu_reset_reason}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/await} {/await}
</div> </div>
<div class="mt-4 flex flex-wrap justify-end gap-2"> <div class="mt-4 flex flex-wrap justify-end gap-2">
{#if $features.sleep} {#if $features.sleep}
<button class="btn btn-primary inline-flex items-center" on:click={confirmSleep} <button class="btn btn-primary inline-flex items-center" on:click={confirmSleep}>
><Sleep class="mr-2 h-5 w-5" /><span>Sleep</span></button <Sleep class="mr-2 h-5 w-5" /><span>Sleep</span>
> </button>
{/if} {/if}
{#if !$features.security || $user.admin} <button class="btn btn-primary inline-flex items-center" on:click={confirmRestart}>
<button class="btn btn-primary inline-flex items-center" on:click={confirmRestart} <Power class="mr-2 h-5 w-5" /><span>Restart</span>
><Power class="mr-2 h-5 w-5" /><span>Restart</span></button </button>
> <button class="btn btn-secondary inline-flex items-center" on:click={confirmReset}>
<button class="btn btn-secondary inline-flex items-center" on:click={confirmReset} <FactoryReset class="mr-2 h-5 w-5" /><span>Factory Reset</span>
><FactoryReset class="mr-2 h-5 w-5" /><span>Factory Reset</span></button </button>
> </div>
{/if}
</div>
</SettingsCard> </SettingsCard>
+9 -10
View File
@@ -1,18 +1,17 @@
<script lang="ts"> <script lang="ts">
import UploadFirmware from './UploadFirmware.svelte'; import UploadFirmware from './UploadFirmware.svelte';
import GithubFirmwareManager from './GithubFirmwareManager.svelte'; import GithubFirmwareManager from './GithubFirmwareManager.svelte';
import { user } from '$lib/stores/user'; import { useFeatureFlags } from '$lib/stores';
import { useFeatureFlags } from '$lib/stores';
const features = useFeatureFlags(); const features = useFeatureFlags();
</script> </script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8"> <div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
{#if $features.download_firmware && (!$features.security || $user.admin)} {#if $features.download_firmware}
<GithubFirmwareManager /> <GithubFirmwareManager />
{/if} {/if}
{#if $features.upload_firmware && (!$features.security || $user.admin)} {#if $features.upload_firmware}
<UploadFirmware /> <UploadFirmware />
{/if} {/if}
</div> </div>
-195
View File
@@ -1,195 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { openModal, closeModal } from 'svelte-modals';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import { PasswordInput } from '$lib/components/input';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EditUser from './EditUser.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { api } from '$lib/api';
import {
Cancel,
Check,
Users,
AddUser,
Admin,
Edit,
Delete,
Warning
} from '$lib/components/icons';
type userSetting = {
username: string;
password: string;
admin: boolean;
};
type SecuritySettings = {
jwt_secret: string;
users: userSetting[];
};
let securitySettings: SecuritySettings;
async function getSecuritySettings() {
const result = await api.get<SecuritySettings>('/api/securitySettings');
if (result.isErr()) {
console.error('Error:', result.inner);
return;
}
securitySettings = result.inner;
}
async function postSecuritySettings(data: SecuritySettings) {
const result = await api.post<SecuritySettings>('/api/securitySettings', data);
if (result.isErr()) {
console.error('Error:', result.inner);
notifications.error('User not authorized.', 3000);
return;
}
securitySettings = result.inner;
if (await validateUser()) {
notifications.success('Security settings updated.', 3000);
}
}
async function validateUser() {
const result = await api.get('/api/verifyAuthorization');
if (result.isErr()) user.invalidate();
return result.isOk();
}
function confirmDelete(index: number) {
openModal(ConfirmDialog, {
title: 'Confirm Delete User',
message:
'Are you sure you want to delete the user "' +
securitySettings.users[index].username +
'"?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Yes', icon: Check }
},
onConfirm: () => {
securitySettings.users.splice(index, 1);
securitySettings = securitySettings;
closeModal();
postSecuritySettings(securitySettings);
}
});
}
function handleEdit(index: number) {
openModal(EditUser, {
title: 'Edit User',
user: { ...securitySettings.users[index] }, // Shallow Copy
onSaveUser: (editedUser: userSetting) => {
securitySettings.users[index] = editedUser;
closeModal();
postSecuritySettings(securitySettings);
}
});
}
function handleNewUser() {
openModal(EditUser, {
title: 'Add User',
onSaveUser: (newUser: userSetting) => {
securitySettings.users = [...securitySettings.users, newUser];
closeModal();
postSecuritySettings(securitySettings);
}
});
//
}
</script>
{#if $user.admin}
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<SettingsCard collapsible={false}>
<Users slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Manage Users</span>
{#await getSecuritySettings()}
<Spinner />
{:then nothing}
<div class="relative w-full overflow-visible">
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
on:click={handleNewUser}
>
<AddUser class="h-6 w-6" /></button
>
<div class="overflow-x-auto" transition:slide|local={{ duration: 300, easing: cubicOut }}>
<table class="table w-full table-auto">
<thead>
<tr class="font-bold">
<th align="left">Username</th>
<th align="center">Admin</th>
<th align="right" class="pr-8">Edit</th>
</tr>
</thead>
<tbody>
{#each securitySettings.users as user, index}
<tr>
<td align="left">{user.username}</td>
<td align="center">
{#if user.admin}
<Admin class="text-secondary" />
{/if}
</td>
<td align="right">
<span class="my-auto inline-flex flex-row space-x-2">
<button
class="btn btn-ghost btn-circle btn-xs"
on:click={() => handleEdit(index)}
>
<Edit class="h-6 w-6" /></button
>
<button
class="btn btn-ghost btn-circle btn-xs"
on:click={() => confirmDelete(index)}
>
<Delete class="text-error h-6 w-6" />
</button>
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<div class="divider mb-0" />
<span class="pb-2 text-xl font-medium">Security Settings</span>
<div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 flex-shrink-0" />
<span
>The JWT secret is used to sign authentication tokens. If you modify the JWT Secret, all
users will be signed out.</span
>
</div>
<label class="label" for="secret">
<span class="label-text text-md">JWT Secret</span>
</label>
<PasswordInput bind:value={securitySettings.jwt_secret} id="secret" />
<div class="mt-6 flex justify-end">
<button class="btn btn-primary" on:click={() => postSecuritySettings(securitySettings)}
>Apply Settings</button
>
</div>
{/await}
</SettingsCard>
</div>
{:else}
{goto('/')}
{/if}
-7
View File
@@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'Users'
};
}) satisfies PageLoad;
-103
View File
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { closeModal } from 'svelte-modals';
import { fly } from 'svelte/transition';
import { PasswordInput } from '$lib/components/input';
import { Cancel, Save } from '$lib/components/icons';
// provided by <Modals />
export let isOpen: boolean;
export let title: string;
export let onSaveUser: any; // Callback on Save
export let user = {
username: '',
password: '',
admin: false
};
let errorUsername = false;
let usernameEditable = false;
onMount(() => {
if (user.username == '') {
usernameEditable = true;
}
});
function handleSave() {
// Validate if username is within range
if (user.username.length < 3 || user.username.length > 32) {
errorUsername = true;
} else {
errorUsername = false;
// Callback on saving
onSaveUser(user);
}
}
</script>
{#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
transition:fly={{ y: 50 }}
on:introstart
on:outroend
>
<div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg md:w-[28rem]"
>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2" />
<form
class="form-control text-base-content mb-1 w-full"
on:submit|preventDefault={handleSave}
novalidate
>
<label class="label" for="username">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
min="3"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2"
bind:value={user.username}
id="username"
disabled={!usernameEditable}
/>
<label for="username" class="label"
><span class="label-text-alt text-error {errorUsername ? '' : 'hidden'}"
>Username must be between 3 and 32 characters long</span
></label
>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<PasswordInput bind:value={user.password} id="pwd" />
<label class="label my-auto cursor-pointer justify-start gap-4">
<input type="checkbox" bind:checked={user.admin} class="checkbox checkbox-primary" />
<span class="">Is Admin?</span>
</label>
<div class="divider my-2" />
<div class="flex justify-end gap-2">
<button
class="btn btn-neutral text-neutral-content inline-flex items-center"
on:click={closeModal}
type="button"
>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span></button
>
<button
class="btn btn-primary text-primary-content inline-flex items-center"
type="submit"
><Save class="mr-2 h-5 w-5" />
Save
</button>
</div>
</form>
</div>
</div>
{/if}
+383 -370
View File
@@ -1,417 +1,430 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { PasswordInput } from '$lib/components/input'; import { PasswordInput } from '$lib/components/input';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
import { user } from '$lib/stores/user'; import { notifications } from '$lib/components/toasts/notifications';
import { page } from '$app/stores'; import Spinner from '$lib/components/Spinner.svelte';
import { notifications } from '$lib/components/toasts/notifications'; import type { ApSettings, ApStatus } from '$lib/types/models';
import Spinner from '$lib/components/Spinner.svelte'; import { api } from '$lib/api';
import type { ApSettings, ApStatus } from '$lib/types/models'; import { useFeatureFlags } from '$lib/stores';
import { api } from '$lib/api'; import { AP, Devices, Home, MAC } from '$lib/components/icons';
import { useFeatureFlags } from '$lib/stores';
import { AP, Devices, Home, MAC } from '$lib/components/icons';
const features = useFeatureFlags(); const features = useFeatureFlags();
let apSettings: ApSettings; let apSettings: ApSettings;
let apStatus: ApStatus; let apStatus: ApStatus;
let formField: any; let formField: any;
async function getAPStatus() { async function getAPStatus() {
const result = await api.get<ApStatus>('/api/apStatus'); const result = await api.get<ApStatus>('/api/apStatus');
if (result.isErr()){ if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return return;
} }
apStatus = result.inner apStatus = result.inner;
return apStatus; return apStatus;
} }
async function getAPSettings() { async function getAPSettings() {
const result = await api.get<ApSettings>('/api/apSetting'); const result = await api.get<ApSettings>('/api/apSetting');
if (result.isErr()){ if (result.isErr()) {
console.error('Error:', result.inner); console.error('Error:', result.inner);
return return;
} }
apSettings = result.inner apSettings = result.inner;
return apSettings; return apSettings;
} }
const interval = setInterval(async () => { const interval = setInterval(async () => {
getAPStatus(); getAPStatus();
}, 5000); }, 5000);
onDestroy(() => clearInterval(interval)); onDestroy(() => clearInterval(interval));
onMount(() => { onMount(getAPSettings);
if (!$features.security || $user.admin) {
getAPSettings();
}
});
let provisionMode = [ let provisionMode = [
{ {
id: 0, id: 0,
text: `Always` text: `Always`
}, },
{ {
id: 1, id: 1,
text: `When WiFi Disconnected` text: `When WiFi Disconnected`
}, },
{ {
id: 2, id: 2,
text: `Never` text: `Never`
} }
]; ];
let apStatusDescription = [ let apStatusDescription = [
{ bg_color: 'bg-success', text_color: 'text-success-content', description: 'Active' }, { bg_color: 'bg-success', text_color: 'text-success-content', description: 'Active' },
{ bg_color: 'bg-error', text_color: 'text-error-content', description: 'Inactive' }, { bg_color: 'bg-error', text_color: 'text-error-content', description: 'Inactive' },
{ bg_color: 'bg-warning', text_color: 'text-warning-content', description: 'Lingering' } { bg_color: 'bg-warning', text_color: 'text-warning-content', description: 'Lingering' }
]; ];
let formErrors = { let formErrors = {
ssid: false, ssid: false,
channel: false, channel: false,
max_clients: false, max_clients: false,
local_ip: false, local_ip: false,
gateway_ip: false, gateway_ip: false,
subnet_mask: false subnet_mask: false
}; };
async function postAPSettings(data: ApSettings) { async function postAPSettings(data: ApSettings) {
const result = await api.post<ApSettings>('/api/apSettings', data); const result = await api.post<ApSettings>('/api/apSettings', data);
if (result.isErr()){ if (result.isErr()) {
notifications.error('User not authorized.', 3000); notifications.error('User not authorized.', 3000);
console.error('Error:', result.inner); console.error('Error:', result.inner);
return return;
} }
notifications.success('Access Point settings updated.', 3000); notifications.success('Access Point settings updated.', 3000);
apSettings = result.inner apSettings = result.inner;
} }
function handleSubmitAP() { function handleSubmitAP() {
let valid = true; let valid = true;
// Validate SSID // Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) { if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false; valid = false;
formErrors.ssid = true; formErrors.ssid = true;
} else { } else {
formErrors.ssid = false; formErrors.ssid = false;
} }
// Validate Channel // Validate Channel
let channel = Number(apSettings.channel); let channel = Number(apSettings.channel);
if (1 > channel || channel > 13) { if (1 > channel || channel > 13) {
valid = false; valid = false;
formErrors.channel = true; formErrors.channel = true;
} else { } else {
formErrors.channel = false; formErrors.channel = false;
} }
// Validate max_clients // Validate max_clients
let maxClients = Number(apSettings.max_clients); let maxClients = Number(apSettings.max_clients);
if (1 > maxClients || maxClients > 8) { if (1 > maxClients || maxClients > 8) {
valid = false; valid = false;
formErrors.max_clients = true; formErrors.max_clients = true;
} else { } else {
formErrors.max_clients = false; formErrors.max_clients = false;
} }
// RegEx for IPv4 // RegEx for IPv4
const regexExp = const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/; /\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
// Validate gateway IP // Validate gateway IP
if (!regexExp.test(apSettings.gateway_ip)) { if (!regexExp.test(apSettings.gateway_ip)) {
valid = false; valid = false;
formErrors.gateway_ip = true; formErrors.gateway_ip = true;
} else { } else {
formErrors.gateway_ip = false; formErrors.gateway_ip = false;
} }
// Validate Subnet Mask // Validate Subnet Mask
if (!regexExp.test(apSettings.subnet_mask)) { if (!regexExp.test(apSettings.subnet_mask)) {
valid = false; valid = false;
formErrors.subnet_mask = true; formErrors.subnet_mask = true;
} else { } else {
formErrors.subnet_mask = false; formErrors.subnet_mask = false;
} }
// Validate local IP // Validate local IP
if (!regexExp.test(apSettings.local_ip)) { if (!regexExp.test(apSettings.local_ip)) {
valid = false; valid = false;
formErrors.local_ip = true; formErrors.local_ip = true;
} else { } else {
formErrors.local_ip = false; formErrors.local_ip = false;
} }
// Submit JSON to REST API // Submit JSON to REST API
if (valid) { if (valid) {
postAPSettings(apSettings); postAPSettings(apSettings);
} }
} }
</script> </script>
<SettingsCard collapsible={false}> <SettingsCard collapsible={false}>
<AP slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" /> <AP slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Access Point</span> <span slot="title">Access Point</span>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
{#await getAPStatus()} {#await getAPStatus()}
<Spinner /> <Spinner />
{:then nothing} {:then nothing}
<div <div
class="flex w-full flex-col space-y-1" class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div <div
class="mask mask-hexagon h-auto w-10 {apStatusDescription[apStatus.status].bg_color}" class="mask mask-hexagon h-auto w-10 {apStatusDescription[apStatus.status]
> .bg_color}"
<AP class="h-auto w-full scale-75 {apStatusDescription[apStatus.status].text_color}" /> >
</div> <AP
<div> class="h-auto w-full scale-75 {apStatusDescription[apStatus.status]
<div class="font-bold">Status</div> .text_color}"
<div class="text-sm opacity-75"> />
{apStatusDescription[apStatus.status].description} </div>
</div> <div>
</div> <div class="font-bold">Status</div>
</div> <div class="text-sm opacity-75">
{apStatusDescription[apStatus.status].description}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<Home class="text-primary-content h-auto w-full scale-75" /> <Home class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">IP Address</div> <div class="font-bold">IP Address</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{apStatus.ip_address} {apStatus.ip_address}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<MAC class="text-primary-content h-auto w-full scale-75" /> <MAC class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">MAC Address</div> <div class="font-bold">MAC Address</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{apStatus.mac_address} {apStatus.mac_address}
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"> <div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10"> <div class="mask mask-hexagon bg-primary h-auto w-10">
<Devices class="text-primary-content h-auto w-full scale-75" /> <Devices class="text-primary-content h-auto w-full scale-75" />
</div> </div>
<div> <div>
<div class="font-bold">AP Clients</div> <div class="font-bold">AP Clients</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{apStatus.station_num} {apStatus.station_num}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/await} {/await}
</div> </div>
{#if !$features.security || $user.admin} <div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden"> <div
<div class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium"
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium" >
> Change AP Settings
Change AP Settings </div>
</div> {#await getAPSettings()}
{#await getAPSettings()} <Spinner />
<Spinner /> {:then nothing}
{:then nothing} <div
<div class="flex flex-col gap-2 p-0"
class="flex flex-col gap-2 p-0" transition:slide|local={{ duration: 300, easing: cubicOut }}
transition:slide|local={{ duration: 300, easing: cubicOut }} >
> <form
<form class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2" on:submit|preventDefault={handleSubmitAP}
on:submit|preventDefault={handleSubmitAP} novalidate
novalidate bind:this={formField}
bind:this={formField} >
> <div>
<div> <label class="label" for="apmode">
<label class="label" for="apmode"> <span class="label-text">Provide Access Point ...</span>
<span class="label-text">Provide Access Point ...</span> </label>
</label> <select
<select class="select select-bordered w-full"
class="select select-bordered w-full" id="apmode"
id="apmode" bind:value={apSettings.provision_mode}
bind:value={apSettings.provision_mode} >
> {#each provisionMode as mode}
{#each provisionMode as mode} <option value={mode.id}>
<option value={mode.id}> {mode.text}
{mode.text} </option>
</option> {/each}
{/each} </select>
</select> </div>
</div> <div>
<div> <label class="label" for="ssid">
<label class="label" for="ssid"> <span class="label-text text-md">SSID</span>
<span class="label-text text-md">SSID</span> </label>
</label> <input
<input type="text"
type="text" class="input input-bordered invalid:border-error w-full invalid:border-2 {(
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.ssid formErrors.ssid
? 'border-error border-2' ) ?
: ''}" 'border-error border-2'
bind:value={apSettings.ssid} : ''}"
id="ssid" bind:value={apSettings.ssid}
min="2" id="ssid"
max="32" min="2"
required max="32"
/> required
<label class="label" for="ssid"> />
<span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}" <label class="label" for="ssid">
>SSID must be between 2 and 32 characters long</span <span
> class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
</label> >SSID must be between 2 and 32 characters long</span
</div> >
</label>
</div>
<div> <div>
<label class="label" for="pwd"> <label class="label" for="pwd">
<span class="label-text text-md">Password</span> <span class="label-text text-md">Password</span>
</label> </label>
<PasswordInput bind:value={apSettings.password} id="pwd" /> <PasswordInput bind:value={apSettings.password} id="pwd" />
</div> </div>
<div> <div>
<label class="label" for="channel"> <label class="label" for="channel">
<span class="label-text text-md">Preferred Channel</span> <span class="label-text text-md">Preferred Channel</span>
</label> </label>
<input <input
type="number" type="number"
min="1" min="1"
max="13" max="13"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.channel class="input input-bordered invalid:border-error w-full invalid:border-2 {(
? 'border-error border-2' formErrors.channel
: ''}" ) ?
bind:value={apSettings.channel} 'border-error border-2'
id="channel" : ''}"
required bind:value={apSettings.channel}
/> id="channel"
<label class="label" for="channel"> required
<span class="label-text-alt text-error {formErrors.channel ? '' : 'hidden'}" />
>Must be channel 1 to 13</span <label class="label" for="channel">
> <span
</label> class="label-text-alt text-error {formErrors.channel ? '' : (
</div> 'hidden'
)}">Must be channel 1 to 13</span
>
</label>
</div>
<div> <div>
<label class="label" for="clients"> <label class="label" for="clients">
<span class="label-text text-md">Max Clients</span> <span class="label-text text-md">Max Clients</span>
</label> </label>
<input <input
type="number" type="number"
min="1" min="1"
max="8" max="8"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.max_clients class="input input-bordered invalid:border-error w-full invalid:border-2 {(
? 'border-error border-2' formErrors.max_clients
: ''}" ) ?
bind:value={apSettings.max_clients} 'border-error border-2'
id="clients" : ''}"
required bind:value={apSettings.max_clients}
/> id="clients"
<label class="label" for="clients"> required
<span class="label-text-alt text-error {formErrors.max_clients ? '' : 'hidden'}" />
>Maximum 8 clients allowed</span <label class="label" for="clients">
> <span
</label> class="label-text-alt text-error {formErrors.max_clients ? '' : (
</div> 'hidden'
)}">Maximum 8 clients allowed</span
>
</label>
</div>
<div> <div>
<label class="label" for="localIP"> <label class="label" for="localIP">
<span class="label-text text-md">Local IP</span> <span class="label-text text-md">Local IP</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.local_ip class="input input-bordered w-full {formErrors.local_ip ?
? 'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.local_ip} bind:value={apSettings.local_ip}
id="localIP" id="localIP"
required required
/> />
<label class="label" for="localIP"> <label class="label" for="localIP">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}" <span
>Must be a valid IPv4 address</span class="label-text-alt text-error {formErrors.local_ip ? '' : (
> 'hidden'
</label> )}">Must be a valid IPv4 address</span
</div> >
</label>
</div>
<div> <div>
<label class="label" for="gateway"> <label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span> <span class="label-text text-md">Gateway IP</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.gateway_ip class="input input-bordered w-full {formErrors.gateway_ip ?
? 'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.gateway_ip} bind:value={apSettings.gateway_ip}
id="gateway" id="gateway"
required required
/> />
<label class="label" for="gateway"> <label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.gateway_ip ? '' : 'hidden'}" <span
>Must be a valid IPv4 address</span class="label-text-alt text-error {formErrors.gateway_ip ? '' : (
> 'hidden'
</label> )}">Must be a valid IPv4 address</span
</div> >
<div> </label>
<label class="label" for="subnet"> </div>
<span class="label-text text-md">Subnet Mask</span> <div>
</label> <label class="label" for="subnet">
<input <span class="label-text text-md">Subnet Mask</span>
type="text" </label>
class="input input-bordered w-full {formErrors.subnet_mask <input
? 'border-error border-2' type="text"
: ''}" class="input input-bordered w-full {formErrors.subnet_mask ?
minlength="7" 'border-error border-2'
maxlength="15" : ''}"
size="15" minlength="7"
bind:value={apSettings.subnet_mask} maxlength="15"
id="subnet" size="15"
required bind:value={apSettings.subnet_mask}
/> id="subnet"
<label class="label" for="subnet"> required
<span class="label-text-alt text-error {formErrors.subnet_mask ? '' : 'hidden'}" />
>Must be a valid IPv4 address</span <label class="label" for="subnet">
> <span
</label> class="label-text-alt text-error {formErrors.subnet_mask ? '' : (
</div> 'hidden'
)}">Must be a valid IPv4 address</span
>
</label>
</div>
<label class="label my-auto cursor-pointer justify-start gap-4"> <label class="label my-auto cursor-pointer justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
bind:checked={apSettings.ssid_hidden} bind:checked={apSettings.ssid_hidden}
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
/> />
<span class="">Hide SSID</span> <span class="">Hide SSID</span>
</label> </label>
<div class="place-self-end"> <div class="place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button> <button class="btn btn-primary" type="submit">Apply Settings</button>
</div> </div>
</form> </form>
</div> </div>
{/await} {/await}
</div> </div>
{/if}
</SettingsCard> </SettingsCard>
+265 -284
View File
@@ -5,8 +5,6 @@
import { openModal, closeModal } from 'svelte-modals'; import { openModal, closeModal } from 'svelte-modals';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications'; import { notifications } from '$lib/components/toasts/notifications';
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list'; import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list';
import SettingsCard from '$lib/components/SettingsCard.svelte'; import SettingsCard from '$lib/components/SettingsCard.svelte';
@@ -441,342 +439,325 @@
{/await} {/await}
</div> </div>
{#if !$features.security || $user.admin} <div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden"> <div
<div class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium"
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium" >
> Saved Networks
Saved Networks </div>
</div> {#await getWifiSettings()}
{#await getWifiSettings()} <Spinner />
<Spinner /> {:then nothing}
{:then nothing} <div class="relative w-full overflow-visible">
<div class="relative w-full overflow-visible"> <button
<button class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16"
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16" on:click={() => {
on:click={() => { if (checkNetworkList()) {
if (checkNetworkList()) { addNetwork();
addNetwork(); showNetworkEditor = true;
showNetworkEditor = true; }
} }}
}} >
> <Add class="h-6 w-6" /></button
<Add class="h-6 w-6" /></button >
> <button
<button class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0" on:click={() => {
on:click={() => { if (checkNetworkList()) {
if (checkNetworkList()) { scanForNetworks();
scanForNetworks(); showNetworkEditor = true;
showNetworkEditor = true; }
} }}
}} >
> <Scan class="h-6 w-6" /></button
<Scan class="h-6 w-6" /></button >
>
<div
class="overflow-x-auto space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<DragDropList
id="networks"
type={VerticalDropZone}
itemSize={60}
itemCount={dndNetworkList.length}
on:drop={onDrop}
let:index
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2"
>
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
<Router class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">{dndNetworkList[index].ssid}</div>
</div>
{#if !$features.security || $user.admin}
<div class="flex-grow" />
<div class="space-x-0 px-0 mx-0">
<button
class="btn btn-ghost btn-sm"
on:click={() => {
handleEdit(index);
}}
>
<Edit class="h-6 w-6" /></button
>
<button
class="btn btn-ghost btn-sm"
on:click={() => {
confirmDelete(index);
}}
>
<Delete class="text-error h-6 w-6" />
</button>
</div>
{/if}
</div>
</DragDropList>
</div>
</div>
<div class="divider mb-0" />
<div <div
class="flex flex-col gap-2 p-0" class="overflow-x-auto space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
<form <DragDropList
class="" id="networks"
on:submit|preventDefault={validateWiFiForm} type={VerticalDropZone}
novalidate itemSize={60}
bind:this={formField} itemCount={dndNetworkList.length}
on:drop={onDrop}
let:index
> >
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
<Router class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">{dndNetworkList[index].ssid}</div>
</div>
<div class="flex-grow" />
<div class="space-x-0 px-0 mx-0">
<button
class="btn btn-ghost btn-sm"
on:click={() => {
handleEdit(index);
}}
>
<Edit class="h-6 w-6" /></button
>
<button
class="btn btn-ghost btn-sm"
on:click={() => {
confirmDelete(index);
}}
>
<Delete class="text-error h-6 w-6" />
</button>
</div>
</div>
</DragDropList>
</div>
</div>
<div class="divider mb-0" />
<div
class="flex flex-col gap-2 p-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<form
class=""
on:submit|preventDefault={validateWiFiForm}
novalidate
bind:this={formField}
>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<div>
<label class="label" for="channel">
<span class="label-text text-md">Host Name</span>
</label>
<input
type="text"
min="1"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrorhostname
) ?
'border-error border-2'
: ''}"
bind:value={wifiSettings.hostname}
id="channel"
required
/>
<label class="label" for="channel">
<span
class="label-text-alt text-error {formErrorhostname ? '' : (
'hidden'
)}">Host name must be between 2 and 32 characters long</span
>
</label>
</div>
<label
class="label inline-flex cursor-pointer content-end justify-start gap-4"
>
<input
type="checkbox"
bind:checked={wifiSettings.priority_RSSI}
class="checkbox checkbox-primary sm:-mb-5"
/>
<span class="sm:-mb-5">Connect to strongest WiFi</span>
</label>
</div>
{#if showNetworkEditor}
<div class="divider my-0" />
<div <div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
<div> <div>
<label class="label" for="channel"> <label class="label" for="ssid">
<span class="label-text text-md">Host Name</span> <span class="label-text text-md">SSID</span>
</label> </label>
<input <input
type="text" type="text"
min="1"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2 {( class="input input-bordered invalid:border-error w-full invalid:border-2 {(
formErrorhostname formErrors.ssid
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={wifiSettings.hostname} bind:value={networkEditable.ssid}
id="channel" id="ssid"
min="2"
max="32"
required required
/> />
<label class="label" for="channel"> <label class="label" for="ssid">
<span <span
class="label-text-alt text-error {formErrorhostname ? '' : ( class="label-text-alt text-error {formErrors.ssid ? '' : (
'hidden' 'hidden'
)}">Host name must be between 2 and 32 characters long</span )}">SSID must be between 3 and 32 characters long</span
> >
</label> </label>
</div> </div>
<div>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<PasswordInput bind:value={networkEditable.password} id="pwd" />
</div>
<label <label
class="label inline-flex cursor-pointer content-end justify-start gap-4" class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4"
> >
<input <input
type="checkbox" type="checkbox"
bind:checked={wifiSettings.priority_RSSI} bind:checked={static_ip_config}
class="checkbox checkbox-primary sm:-mb-5" class="checkbox checkbox-primary sm:-mb-5"
/> />
<span class="sm:-mb-5">Connect to strongest WiFi</span> <span class="sm:-mb-5">Static IP Config?</span>
</label> </label>
</div> </div>
{#if static_ip_config}
{#if showNetworkEditor}
<div class="divider my-0" />
<div <div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
> >
<div> <div>
<label class="label" for="ssid"> <label class="label" for="localIP">
<span class="label-text text-md">SSID</span> <span class="label-text text-md">Local IP</span>
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {( class="input input-bordered w-full {formErrors.local_ip ?
formErrors.ssid
) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={networkEditable.ssid} minlength="7"
id="ssid" maxlength="15"
min="2" size="15"
max="32" bind:value={networkEditable.local_ip}
id="localIP"
required required
/> />
<label class="label" for="ssid"> <label class="label" for="localIP">
<span <span
class="label-text-alt text-error {formErrors.ssid ? '' class="label-text-alt text-error {formErrors.local_ip ?
: 'hidden'}" ''
>SSID must be between 3 and 32 characters long</span : 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.gateway_ip ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.gateway_ip}
required
/>
<label class="label" for="gateway">
<span
class="label-text-alt text-error {(
formErrors.gateway_ip
) ?
''
: 'hidden'}">Must be a valid IPv4 address</span
> >
</label> </label>
</div> </div>
<div> <div>
<label class="label" for="pwd"> <label class="label" for="subnet">
<span class="label-text text-md">Password</span> <span class="label-text text-md">Subnet Mask</span>
</label> </label>
<PasswordInput bind:value={networkEditable.password} id="pwd" />
</div>
<label
class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4"
>
<input <input
type="checkbox" type="text"
bind:checked={static_ip_config} class="input input-bordered w-full {formErrors.subnet_mask ?
class="checkbox checkbox-primary sm:-mb-5" 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.subnet_mask}
required
/> />
<span class="sm:-mb-5">Static IP Config?</span> <label class="label" for="subnet">
</label> <span
</div> class="label-text-alt text-error {(
{#if static_ip_config}
<div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div>
<label class="label" for="localIP">
<span class="label-text text-md">Local IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {(
formErrors.local_ip
) ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.local_ip}
id="localIP"
required
/>
<label class="label" for="localIP">
<span
class="label-text-alt text-error {(
formErrors.local_ip
) ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {(
formErrors.gateway_ip
) ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.gateway_ip}
required
/>
<label class="label" for="gateway">
<span
class="label-text-alt text-error {(
formErrors.gateway_ip
) ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">Subnet Mask</span>
</label>
<input
type="text"
class="input input-bordered w-full {(
formErrors.subnet_mask formErrors.subnet_mask
) ? ) ?
'border-error border-2' ''
: ''}" : 'hidden'}"
minlength="7" >
maxlength="15" Must be a valid IPv4 address
size="15" </span>
bind:value={networkEditable.subnet_mask} </label>
required
/>
<label class="label" for="subnet">
<span
class="label-text-alt text-error {(
formErrors.subnet_mask
) ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">DNS 1</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_1 ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_1}
required
/>
<label class="label" for="gateway">
<span
class="label-text-alt text-error {formErrors.dns_1 ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">DNS 2</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_2 ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_2}
required
/>
<label class="label" for="subnet">
<span
class="label-text-alt text-error {formErrors.dns_2 ?
''
: 'hidden'}">Must be a valid IPv4 address</span
>
</label>
</div>
</div> </div>
{/if} <div>
<label class="label" for="gateway">
<span class="label-text text-md">DNS 1</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_1 ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_1}
required
/>
<label class="label" for="gateway">
<span
class="label-text-alt text-error {formErrors.dns_1 ? ''
: 'hidden'}"
>
Must be a valid IPv4 address
</span>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">DNS 2</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_2 ?
'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_2}
required
/>
<label class="label" for="subnet">
<span
class="label-text-alt text-error {formErrors.dns_2 ? ''
: 'hidden'}"
>
Must be a valid IPv4 address
</span>
</label>
</div>
</div>
{/if} {/if}
{/if}
<div class="divider mb-2 mt-0" /> <div class="divider mb-2 mt-0" />
<div class="mx-4 flex flex-wrap justify-end gap-2"> <div class="mx-4 flex flex-wrap justify-end gap-2">
<button <button class="btn btn-primary" type="submit" disabled={!showNetworkEditor}>
class="btn btn-primary" {newNetwork ? 'Add Network' : 'Update Network'}
type="submit" </button>
disabled={!showNetworkEditor} <button class="btn btn-primary" type="button" on:click={validateHostName}>
>{newNetwork ? 'Add Network' : 'Update Network'}</button Apply Settings
> </button>
<button </div>
class="btn btn-primary" </form>
type="button" </div>
on:click={validateHostName}>Apply Settings</button {/await}
> </div>
</div>
</form>
</div>
{/await}
</div>
{/if}
</SettingsCard> </SettingsCard>
+20 -25
View File
@@ -4,31 +4,26 @@
The back end exposes a number of API endpoints which are referenced in the table below. The back end exposes a number of API endpoints which are referenced in the table below.
| Method | Endpoint | Authentication | POST JSON Body | Info | | Method | Endpoint | Authentication | POST JSON Body | Info |
| ------ | --------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | ------ | -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| GET | /rest/features | `NONE_REQUIRED` | none | Tells the client which features of the UI should be use | | GET | /rest/features | `NONE_REQUIRED` | none | Tells the client which features of the UI should be use |
| GET | /rest/ntpStatus | `IS_AUTHENTICATED` | none | Current NTP connection status | | GET | /rest/ntpStatus | `IS_AUTHENTICATED` | none | Current NTP connection status |
| GET | /rest/ntpSettings | `IS_ADMIN` | none | Current NTP settings | | GET | /rest/ntpSettings | `IS_ADMIN` | none | Current NTP settings |
| POST | /rest/ntpSettings | `IS_ADMIN` | `{"enabled": true,"server": "time.google.com","tz_label": "Europe/London","tz_format": "GMT0BST,M3.5.0/1,M10.5.0"}` | Update the NTP settings | | POST | /rest/ntpSettings | `IS_ADMIN` | `{"enabled": true,"server": "time.google.com","tz_label": "Europe/London","tz_format": "GMT0BST,M3.5.0/1,M10.5.0"}` | Update the NTP settings |
| GET | /rest/apStatus | `IS_AUTHENTICATED` | none | Current AP status and client information | | GET | /rest/apStatus | `IS_AUTHENTICATED` | none | Current AP status and client information |
| GET | /rest/apSettings | `IS_ADMIN` | none | Current AP settings | | GET | /rest/apSettings | `IS_ADMIN` | none | Current AP settings |
| POST | /rest/apSettings | `IS_ADMIN` | `{"provision_mode": 1,"ssid": "ESP32-SvelteKit-e89f6d20372c","password": "esp-sveltekit","channel": 1,"ssid_hidden": false,"max_clients": 4,"local_ip": "192.168.4.1","gateway_ip": "192.168.4.1","subnet_mask": "255.255.255.0"}` | Update AP settings | | POST | /rest/apSettings | `IS_ADMIN` | `{"provision_mode": 1,"ssid": "ESP32-SvelteKit-e89f6d20372c","password": "esp-sveltekit","channel": 1,"ssid_hidden": false,"max_clients": 4,"local_ip": "192.168.4.1","gateway_ip": "192.168.4.1","subnet_mask": "255.255.255.0"}` | Update AP settings |
| GET | /rest/wifiStatus | `IS_AUTHENTICATED` | none | Current status of the wifi client connection | | GET | /rest/wifiStatus | `IS_AUTHENTICATED` | none | Current status of the wifi client connection |
| GET | /rest/scanNetworks | `IS_ADMIN` | none | Async Scan for Networks in Range | | GET | /rest/scanNetworks | `IS_ADMIN` | none | Async Scan for Networks in Range |
| GET | /rest/listNetworks | `IS_ADMIN` | none | List networks in range after successful scanning. Otherwise triggers scanning. | | GET | /rest/listNetworks | `IS_ADMIN` | none | List networks in range after successful scanning. Otherwise triggers scanning. |
| GET | /rest/wifiSettings | `IS_ADMIN` | none | Current WiFi settings | | GET | /rest/wifiSettings | `IS_ADMIN` | none | Current WiFi settings |
| POST | /rest/wifiSettings | `IS_ADMIN` | `{"hostname":"esp32-f412fa4495f8","priority_RSSI":true,"wifi_networks":[{"ssid":"YourSSID","password":"YourPassword","static_ip_config":false}]}` | Update WiFi settings and credentials | | POST | /rest/wifiSettings | `IS_ADMIN` | `{"hostname":"esp32-f412fa4495f8","priority_RSSI":true,"wifi_networks":[{"ssid":"YourSSID","password":"YourPassword","static_ip_config":false}]}` | Update WiFi settings and credentials |
| GET | /rest/systemStatus | `IS_AUTHENTICATED` | none | Get system information about the ESP. | | GET | /rest/systemStatus | `IS_AUTHENTICATED` | none | Get system information about the ESP. |
| POST | /rest/restart | `IS_ADMIN` | none | Restart the ESP32 | | POST | /rest/restart | `IS_ADMIN` | none | Restart the ESP32 |
| POST | /rest/factoryReset | `IS_ADMIN` | none | Reset the ESP32 and all settings to their default values | | POST | /rest/factoryReset | `IS_ADMIN` | none | Reset the ESP32 and all settings to their default values |
| POST | /rest/uploadFirmware | `IS_ADMIN` | none | File upload of firmware.bin | | POST | /rest/uploadFirmware | `IS_ADMIN` | none | File upload of firmware.bin |
| POST | /rest/signIn | `NONE_REQUIRED` | `{"password": "admin","username": "admin"}` | Signs a user in and returns access token | | POST | /rest/sleep | `IS_AUTHENTICATED` | none | Puts the device in deep sleep mode |
| GET | /rest/securitySettings | `IS_ADMIN` | none | retrieves all user information and roles | | POST | /rest/downloadUpdate | `IS_ADMIN` | `{"download_url": "https://github.com/theelims/ESP32-sveltekit/releases/download/v0.1.0/firmware_esp32s3.bin"}` | Download link for OTA. This requires a valid SSL certificate and will follow redirects. |
| POST | /rest/securitySettings | `IS_ADMIN` | `{"jwt_secret": "734cb5bb-5597b722", "users": [{"username": "admin", "password": "admin", "admin": true}, {"username": "guest", "password": "guest", "admin": false, }]}` | retrieves all user information and roles |
| GET | /rest/verifyAuthorization | `NONE_REQUIRED` | none | Verifies the content of the auth bearer token |
| GET | /rest/generateToken?username={username} | `IS_ADMIN` | `{"token": "734cb5bb-5597b722"}` | Generates a new JWT token for the user from username |
| POST | /rest/sleep | `IS_AUTHENTICATED` | none | Puts the device in deep sleep mode |
| POST | /rest/downloadUpdate | `IS_ADMIN` | `{"download_url": "https://github.com/theelims/ESP32-sveltekit/releases/download/v0.1.0/firmware_esp32s3.bin"}` | Download link for OTA. This requires a valid SSL certificate and will follow redirects. |
<!-- | HTTP Method | Endpoint | Description | Parameters | <!-- | HTTP Method | Endpoint | Description | Parameters |
|-------------|----------------|----------------------------|---------------------------| |-------------|----------------|----------------------------|---------------------------|
+29 -29
View File
@@ -1,23 +1,22 @@
# Software description # Software description
The software make use of a range of different libraries to enhance the functionality. The software make use of a range of different libraries to enhance the functionality.
Up to date list can be seen in platformio.ini file. Up to date list can be seen in platformio.ini file.
The libraries includes: The libraries includes:
* Esp32SvelteKit - Esp32SvelteKit
* PsychicHttp - PsychicHttp
* ArduinoJson - ArduinoJson
* Adafruit SSD1306 - Adafruit SSD1306
* Adafruit GFX Library - Adafruit GFX Library
* Adafruit BusIO - Adafruit BusIO
* Adafruit PWM Servo Driver Library - Adafruit PWM Servo Driver Library
* Adafruit ADS1X15 - Adafruit ADS1X15
* Adafruit HMC5883 Unified - Adafruit HMC5883 Unified
* Adafruit Unified Sensor - Adafruit Unified Sensor
* I2Cdevlib-MPU6050 - I2Cdevlib-MPU6050
* NewPing - NewPing
* SPI - SPI
#### Structure #### Structure
@@ -36,7 +35,6 @@ To dis-/enable the major feature defines are used. Define them in either feature
| --- | --- | --- | --- | --- | ---
| FT_BATTERY | Whether or not to use battery | 0 | FT_BATTERY | Whether or not to use battery | 0
| FT_NTP | Whether or not to use time server | 1 | FT_NTP | Whether or not to use time server | 1
| FT_SECURITY | Whether or not to use login system | 0
| FT_SLEEP | Whether or not include sleep management | 0 | FT_SLEEP | Whether or not include sleep management | 0
| FT_UPLOAD_FIRMWARE | Whether or not to use OAT | 0 | FT_UPLOAD_FIRMWARE | Whether or not to use OAT | 0
| FT_DOWNLOAD_FIRMWARE | Whether or not to use github for firmware updates | 0 | FT_DOWNLOAD_FIRMWARE | Whether or not to use github for firmware updates | 0
@@ -50,7 +48,9 @@ To dis-/enable the major feature defines are used. Define them in either feature
### 📲 Controller ### 📲 Controller
The controller is a SvelteKit app, which main focus is to calibrate and control the robot. The controller is a SvelteKit app, which main focus is to calibrate and control the robot.
<!-- Write about the emulation, stream, controls and link to the space issues --> <!-- Write about the emulation, stream, controls and link to the space issues -->
It is made to be included and hosted by the robot. It is made to be included and hosted by the robot.
Therefore there is placed a lot of thought behind the functionality and dependencies. Therefore there is placed a lot of thought behind the functionality and dependencies.
@@ -58,22 +58,22 @@ Therefore there is placed a lot of thought behind the functionality and dependen
For the development dependencies I choose the following For the development dependencies I choose the following
| Dependencies | Description | Dependencies | Description |
| --- | --- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SvelteKit | SvelteKit is an application framework built on top of Svelte, enhancing it with features like routing, server-side rendering, and static site generation. It streamlines the development process by integrating server-side capabilities with Svelte's client-side benefits. Furthermore it make the development process fast and enjoyable. | SvelteKit | SvelteKit is an application framework built on top of Svelte, enhancing it with features like routing, server-side rendering, and static site generation. It streamlines the development process by integrating server-side capabilities with Svelte's client-side benefits. Furthermore it make the development process fast and enjoyable. |
| Vite | Vite is a frontend tool that is used for building fast and optimized web applications. Is serves code local during development and bundles assets for production | Vite | Vite is a frontend tool that is used for building fast and optimized web applications. Is serves code local during development and bundles assets for production |
| Typescript | TypeScript's integration of static typing enhances code reliability and maintainability. | Typescript | TypeScript's integration of static typing enhances code reliability and maintainability. |
| Tailwind CSS | Tailwind CSS accelerates web development with its utility-first approach, ensuring rapid styling and consistent design. | Tailwind CSS | Tailwind CSS accelerates web development with its utility-first approach, ensuring rapid styling and consistent design. |
#### Libraries #### Libraries
For the app functionality I choose the following: For the app functionality I choose the following:
| Dependencies | Description | Dependencies | Description |
| --- | --- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [Three](https://www.npmjs.com/package/three) | Easy to use, lightweight, cross-browser, general purpose 3D library. | [Three](https://www.npmjs.com/package/three) | Easy to use, lightweight, cross-browser, general purpose 3D library. |
| [Urdf-loader](https://www.npmjs.com/package/urdf-loader) | Utilities for loading URDF files into THREE.js and a Web Component that loads and renders the model. | [Urdf-loader](https://www.npmjs.com/package/urdf-loader) | Utilities for loading URDF files into THREE.js and a Web Component that loads and renders the model. |
| [Xacro-parser](https://www.npmjs.com/package/xacro-parser) | Javascript parser and loader for processing the ROS Xacro file format. | [Xacro-parser](https://www.npmjs.com/package/xacro-parser) | Javascript parser and loader for processing the ROS Xacro file format. |
| [NippleJS](https://www.npmjs.com/package/nipplejs) | A vanilla virtual joystick for touch capable interfaces. | [NippleJS](https://www.npmjs.com/package/nipplejs) | A vanilla virtual joystick for touch capable interfaces. |
| [Uzip](https://www.npmjs.com/package/uzip) | Simple, tiny and fast ZIP library. | [Uzip](https://www.npmjs.com/package/uzip) | Simple, tiny and fast ZIP library. |
| [ChartJS](https://www.npmjs.com/package/chart.js) | Simple and flexible charting library. | [ChartJS](https://www.npmjs.com/package/chart.js) | Simple and flexible charting library. |