🎨 format
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { page } from '$app/state'
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center items-center w-full h-full">
|
||||
<h1>{page.status} {page.error?.message}</h1>
|
||||
<span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
|
||||
<h1>{page.status} {page.error?.message}</h1>
|
||||
<span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
|
||||
</div>
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { Modals, modals } from 'svelte-modals'
|
||||
import Toast from '$lib/components/toasts/Toast.svelte'
|
||||
import { notifications } from '$lib/components/toasts/notifications'
|
||||
import { fade } from 'svelte/transition'
|
||||
import '../app.css'
|
||||
import Menu from '../lib/components/menu/Menu.svelte'
|
||||
import Statusbar from '../lib/components/statusbar/statusbar.svelte'
|
||||
import {
|
||||
telemetry,
|
||||
analytics,
|
||||
ModesEnum,
|
||||
kinematicData,
|
||||
mode,
|
||||
outControllerData,
|
||||
servoAngles,
|
||||
servoAnglesOut,
|
||||
socket,
|
||||
location,
|
||||
useFeatureFlags,
|
||||
walkGait
|
||||
} from '$lib/stores'
|
||||
import { type Analytics, type DownloadOTA } from '$lib/types/models'
|
||||
import { MessageTopic } from '$lib/types/models'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { Modals, modals } from 'svelte-modals'
|
||||
import Toast from '$lib/components/toasts/Toast.svelte'
|
||||
import { notifications } from '$lib/components/toasts/notifications'
|
||||
import { fade } from 'svelte/transition'
|
||||
import '../app.css'
|
||||
import Menu from '../lib/components/menu/Menu.svelte'
|
||||
import Statusbar from '../lib/components/statusbar/statusbar.svelte'
|
||||
import {
|
||||
telemetry,
|
||||
analytics,
|
||||
ModesEnum,
|
||||
kinematicData,
|
||||
mode,
|
||||
outControllerData,
|
||||
servoAngles,
|
||||
servoAnglesOut,
|
||||
socket,
|
||||
location,
|
||||
useFeatureFlags,
|
||||
walkGait
|
||||
} from '$lib/stores'
|
||||
import { type Analytics, type DownloadOTA } from '$lib/types/models'
|
||||
import { MessageTopic } from '$lib/types/models'
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { children }: Props = $props()
|
||||
let { children }: Props = $props()
|
||||
|
||||
const features = useFeatureFlags()
|
||||
const features = useFeatureFlags()
|
||||
|
||||
onMount(async () => {
|
||||
const ws = $location ? $location : window.location.host
|
||||
socket.init(`ws://${ws}/api/ws`)
|
||||
onMount(async () => {
|
||||
const ws = $location ? $location : window.location.host
|
||||
socket.init(`ws://${ws}/api/ws`)
|
||||
|
||||
addEventListeners()
|
||||
addEventListeners()
|
||||
|
||||
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
|
||||
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
|
||||
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
|
||||
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
|
||||
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
removeEventListeners()
|
||||
})
|
||||
|
||||
const addEventListeners = () => {
|
||||
socket.on('open', handleOpen)
|
||||
socket.on('close', handleClose)
|
||||
socket.on('error', handleError)
|
||||
socket.on(MessageTopic.rssi, handleNetworkStatus)
|
||||
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
|
||||
socket.on(MessageTopic.analytics, handleAnalytics)
|
||||
socket.on(MessageTopic.angles, (angles: number[]) => {
|
||||
if (angles.length) servoAngles.set(angles)
|
||||
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
|
||||
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
|
||||
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
|
||||
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
|
||||
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
|
||||
})
|
||||
features.subscribe(data => {
|
||||
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
|
||||
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
|
||||
|
||||
onDestroy(() => {
|
||||
removeEventListeners()
|
||||
})
|
||||
}
|
||||
|
||||
const removeEventListeners = () => {
|
||||
socket.off(MessageTopic.analytics, handleAnalytics)
|
||||
socket.off('open', handleOpen)
|
||||
socket.off('close', handleClose)
|
||||
socket.off(MessageTopic.rssi, handleNetworkStatus)
|
||||
socket.off(MessageTopic.otastatus, handleOAT)
|
||||
}
|
||||
const addEventListeners = () => {
|
||||
socket.on('open', handleOpen)
|
||||
socket.on('close', handleClose)
|
||||
socket.on('error', handleError)
|
||||
socket.on(MessageTopic.rssi, handleNetworkStatus)
|
||||
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
|
||||
socket.on(MessageTopic.analytics, handleAnalytics)
|
||||
socket.on(MessageTopic.angles, (angles: number[]) => {
|
||||
if (angles.length) servoAngles.set(angles)
|
||||
})
|
||||
features.subscribe(data => {
|
||||
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
|
||||
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
notifications.success('Connection to device established', 5000)
|
||||
}
|
||||
const removeEventListeners = () => {
|
||||
socket.off(MessageTopic.analytics, handleAnalytics)
|
||||
socket.off('open', handleOpen)
|
||||
socket.off('close', handleClose)
|
||||
socket.off(MessageTopic.rssi, handleNetworkStatus)
|
||||
socket.off(MessageTopic.otastatus, handleOAT)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
notifications.error('Connection to device lost', 5000)
|
||||
telemetry.setRSSI(0)
|
||||
}
|
||||
const handleOpen = () => {
|
||||
notifications.success('Connection to device established', 5000)
|
||||
}
|
||||
|
||||
const handleError = (data: any) => console.error(data)
|
||||
const handleClose = () => {
|
||||
notifications.error('Connection to device lost', 5000)
|
||||
telemetry.setRSSI(0)
|
||||
}
|
||||
|
||||
const handleAnalytics = (data: Analytics) => analytics.addData(data)
|
||||
const handleError = (data: any) => console.error(data)
|
||||
|
||||
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
|
||||
const handleAnalytics = (data: Analytics) => analytics.addData(data)
|
||||
|
||||
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
|
||||
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
|
||||
|
||||
let menuOpen = $state(false)
|
||||
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
|
||||
|
||||
let menuOpen = $state(false)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{page.data.title}</title>
|
||||
<title>{page.data.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="drawer">
|
||||
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
|
||||
<div class="drawer-content flex flex-col">
|
||||
<!-- Status bar content here -->
|
||||
<Statusbar />
|
||||
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
|
||||
<div class="drawer-content flex flex-col">
|
||||
<!-- Status bar content here -->
|
||||
<Statusbar />
|
||||
|
||||
<!-- Main page content here -->
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<!-- Side Navigation -->
|
||||
<div class="drawer-side z-30 shadow-lg">
|
||||
<label for="main-menu" class="drawer-overlay"></label>
|
||||
<Menu menuClicked={() => (menuOpen = false)} />
|
||||
</div>
|
||||
<!-- Main page content here -->
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<!-- Side Navigation -->
|
||||
<div class="drawer-side z-30 shadow-lg">
|
||||
<label for="main-menu" class="drawer-overlay"></label>
|
||||
<Menu menuClicked={() => (menuOpen = false)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modals>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#snippet backdrop()}
|
||||
<div
|
||||
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
|
||||
transition:fade
|
||||
onclick={modals.closeAll}>
|
||||
</div>
|
||||
{/snippet}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#snippet backdrop()}
|
||||
<div
|
||||
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
|
||||
transition:fade
|
||||
onclick={modals.closeAll}
|
||||
></div>
|
||||
{/snippet}
|
||||
</Modals>
|
||||
|
||||
<Toast />
|
||||
|
||||
+16
-14
@@ -2,21 +2,23 @@ export const prerender = true
|
||||
export const ssr = false
|
||||
|
||||
const registerFetchIntercept = async () => {
|
||||
const { fetch: originalFetch } = window
|
||||
const fileService = (await import('$lib/services/file-service')).default
|
||||
window.fetch = async (resource, config) => {
|
||||
const url = resource instanceof Request ? resource.url : resource.toString()
|
||||
const file = await fileService?.getFile(url)
|
||||
return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config)
|
||||
}
|
||||
const { fetch: originalFetch } = window
|
||||
const fileService = (await import('$lib/services/file-service')).default
|
||||
window.fetch = async (resource, config) => {
|
||||
const url = resource instanceof Request ? resource.url : resource.toString()
|
||||
const file = await fileService?.getFile(url)
|
||||
return file?.isOk() && file.inner ?
|
||||
new Response(new Uint8Array(file.inner))
|
||||
: originalFetch(resource, config)
|
||||
}
|
||||
}
|
||||
|
||||
export const load = async () => {
|
||||
await registerFetchIntercept()
|
||||
return {
|
||||
title: 'Spot micro controller',
|
||||
github: 'runeharlyk/SpotMicroESP32-Leika',
|
||||
app_name: 'Spot Micro Controller',
|
||||
copyright: '2025 Rune Harlyk'
|
||||
}
|
||||
await registerFetchIntercept()
|
||||
return {
|
||||
title: 'Spot micro controller',
|
||||
github: 'runeharlyk/SpotMicroESP32-Leika',
|
||||
app_name: 'Spot Micro Controller',
|
||||
copyright: '2025 Rune Harlyk'
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import Visualization from '$lib/components/Visualization.svelte'
|
||||
import { socket } from '$lib/stores'
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Visualization from '$lib/components/Visualization.svelte'
|
||||
import { socket } from '$lib/stores'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
onMount(() => {
|
||||
socket.subscribe(isConnected => {
|
||||
if (isConnected) {
|
||||
goto('/controller')
|
||||
}
|
||||
onMount(() => {
|
||||
socket.subscribe(isConnected => {
|
||||
if (isConnected) {
|
||||
goto('/controller')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="hero bg-base-100 h-screen">
|
||||
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
|
||||
<div class="w-64 h-64">
|
||||
<Visualization sky={false} orbit panel={false} ground={false} />
|
||||
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
|
||||
<div class="w-64 h-64">
|
||||
<Visualization sky={false} orbit panel={false} ground={false} />
|
||||
</div>
|
||||
<div class="card-body w-80">
|
||||
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
|
||||
<p class="py-6 text-center"></p>
|
||||
<a class="btn btn-primary" href={$socket ? '/controller' : '/connection'}>
|
||||
Add Robot Dog
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body w-80">
|
||||
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
|
||||
<p class="py-6 text-center"></p>
|
||||
<a class="btn btn-primary" href={$socket ? '/controller' : '/connection'}> Add Robot Dog </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Connection from './Connection.svelte';
|
||||
import Connection from './Connection.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<Connection />
|
||||
<Connection />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'Connection'
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
title: 'Connection'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<script lang="ts">
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import { WiFi } from '$lib/components/icons';
|
||||
import { location, socket } from '$lib/stores';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { WiFi } from '$lib/components/icons'
|
||||
import { location, socket } from '$lib/stores'
|
||||
|
||||
const update = () => {
|
||||
const ws = $location ? $location : window.location.host;
|
||||
socket.init(`ws://${ws}/api/ws/events`);
|
||||
};
|
||||
const update = () => {
|
||||
const ws = $location ? $location : window.location.host
|
||||
socket.init(`ws://${ws}/api/ws/events`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Connection</span>
|
||||
{/snippet}
|
||||
{#snippet icon()}
|
||||
<WiFi class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Connection</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex">
|
||||
<label class="label w-32" for="server">Address:</label>
|
||||
<input class="input" bind:value={$location} />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<label class="label w-32" for="server">Address:</label>
|
||||
<input class="input" bind:value={$location} />
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick={update}>Update</button>
|
||||
<button class="btn btn-primary" onclick={update}>Update</button>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<script lang="ts">
|
||||
import Controls from './Controls.svelte'
|
||||
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'
|
||||
import { selectedView, views } from '$lib/stores/application'
|
||||
import { onMount } from 'svelte'
|
||||
import { mpu, socket } from '$lib/stores'
|
||||
import { imu } from '$lib/stores/imu'
|
||||
import { MessageTopic, type IMU } from '$lib/types/models'
|
||||
import Controls from './Controls.svelte'
|
||||
import WidgetContainer from '$lib/components/layout/WidgetContainer.svelte'
|
||||
import { selectedView, views } from '$lib/stores/application'
|
||||
import { onMount } from 'svelte'
|
||||
import { mpu, socket } from '$lib/stores'
|
||||
import { imu } from '$lib/stores/imu'
|
||||
import { MessageTopic, type IMU } from '$lib/types/models'
|
||||
|
||||
let layout = $derived($views.find(v => v.name === $selectedView)!)
|
||||
let layout = $derived($views.find(v => v.name === $selectedView)!)
|
||||
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.imu, (data: IMU) => {
|
||||
imu.addData(data)
|
||||
if (data.heading)
|
||||
mpu.update(mpuData => {
|
||||
mpuData.heading = data.heading
|
||||
console.log(data.heading)
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.imu, (data: IMU) => {
|
||||
imu.addData(data)
|
||||
if (data.heading)
|
||||
mpu.update(mpuData => {
|
||||
mpuData.heading = data.heading
|
||||
console.log(data.heading)
|
||||
|
||||
return mpuData
|
||||
return mpuData
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="absolute top-0 select-none w-screen h-screen">
|
||||
<Controls />
|
||||
<div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12">
|
||||
<WidgetContainer container={layout.content} />
|
||||
</div>
|
||||
<Controls />
|
||||
<div class="absolute w-full h-screen top-0 overflow-hidden lg:pt-16 pt-12">
|
||||
<WidgetContainer container={layout.content} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const load = async () => {
|
||||
return { title: 'Controller' };
|
||||
};
|
||||
return { title: 'Controller' }
|
||||
}
|
||||
|
||||
@@ -1,202 +1,210 @@
|
||||
<script lang="ts">
|
||||
import nipplejs from 'nipplejs'
|
||||
import { onMount } from 'svelte'
|
||||
import { capitalize, throttler } from '$lib/utilities'
|
||||
import {
|
||||
input,
|
||||
outControllerData,
|
||||
mode,
|
||||
modes,
|
||||
type Modes,
|
||||
ModesEnum,
|
||||
walkGaits,
|
||||
WalkGaits,
|
||||
walkGait,
|
||||
walkGaitLabels
|
||||
} from '$lib/stores'
|
||||
import type { vector } from '$lib/types/models'
|
||||
import { VerticalSlider } from '$lib/components/input'
|
||||
import { gamepadAxes, hasGamepad } from '$lib/stores/gamepad'
|
||||
import { notifications } from '$lib/components/toasts/notifications'
|
||||
import nipplejs from 'nipplejs'
|
||||
import { onMount } from 'svelte'
|
||||
import { capitalize, throttler } from '$lib/utilities'
|
||||
import {
|
||||
input,
|
||||
outControllerData,
|
||||
mode,
|
||||
modes,
|
||||
type Modes,
|
||||
ModesEnum,
|
||||
walkGaits,
|
||||
WalkGaits,
|
||||
walkGait,
|
||||
walkGaitLabels
|
||||
} from '$lib/stores'
|
||||
import type { vector } from '$lib/types/models'
|
||||
import { VerticalSlider } from '$lib/components/input'
|
||||
import { gamepadAxes, hasGamepad } from '$lib/stores/gamepad'
|
||||
import { notifications } from '$lib/components/toasts/notifications'
|
||||
|
||||
let throttle = new throttler()
|
||||
let left: nipplejs.JoystickManager
|
||||
let right: nipplejs.JoystickManager
|
||||
let throttle = new throttler()
|
||||
let left: nipplejs.JoystickManager
|
||||
let right: nipplejs.JoystickManager
|
||||
|
||||
let throttle_timing = 40
|
||||
let data = new Array(7)
|
||||
let throttle_timing = 40
|
||||
let data = new Array(7)
|
||||
|
||||
$effect(() => {
|
||||
if ($hasGamepad) {
|
||||
notifications.success('🎮 Gamepad connected', 3000)
|
||||
$effect(() => {
|
||||
if ($hasGamepad) {
|
||||
notifications.success('🎮 Gamepad connected', 3000)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
handleJoyMove('left', { x: $gamepadAxes[0], y: -$gamepadAxes[1] })
|
||||
handleJoyMove('right', { x: $gamepadAxes[2], y: $gamepadAxes[3] })
|
||||
})
|
||||
|
||||
// TODO React to button press
|
||||
// $effect(() => {
|
||||
// if ($gamepadButtons.length === 0) return
|
||||
//
|
||||
// })
|
||||
|
||||
onMount(() => {
|
||||
left = nipplejs.create({
|
||||
zone: document.getElementById('left') as HTMLElement,
|
||||
color: '#15191e80',
|
||||
dynamicPage: true,
|
||||
mode: 'static',
|
||||
restOpacity: 1
|
||||
})
|
||||
|
||||
right = nipplejs.create({
|
||||
zone: document.getElementById('right') as HTMLElement,
|
||||
color: '#15191e80',
|
||||
dynamicPage: true,
|
||||
mode: 'static',
|
||||
restOpacity: 1
|
||||
})
|
||||
|
||||
left.on('move', (_, data) => handleJoyMove('left', data.vector))
|
||||
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }))
|
||||
right.on('move', (_, data) => handleJoyMove('right', data.vector))
|
||||
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }))
|
||||
})
|
||||
|
||||
const handleJoyMove = (key: 'left' | 'right', data: vector) => {
|
||||
input.update(inputData => {
|
||||
inputData[key] = data
|
||||
return inputData
|
||||
})
|
||||
throttle.throttle(updateData, throttle_timing)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
handleJoyMove('left', { x: $gamepadAxes[0], y: -$gamepadAxes[1] })
|
||||
handleJoyMove('right', { x: $gamepadAxes[2], y: $gamepadAxes[3] })
|
||||
})
|
||||
const updateData = () => {
|
||||
data[0] = $input.left.x
|
||||
data[1] = $input.left.y
|
||||
data[2] = $input.right.x
|
||||
data[3] = $input.right.y
|
||||
data[4] = $input.height
|
||||
data[5] = $input.speed
|
||||
data[6] = $input.s1
|
||||
|
||||
// TODO React to button press
|
||||
// $effect(() => {
|
||||
// if ($gamepadButtons.length === 0) return
|
||||
//
|
||||
// })
|
||||
outControllerData.set(data)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
left = nipplejs.create({
|
||||
zone: document.getElementById('left') as HTMLElement,
|
||||
color: '#15191e80',
|
||||
dynamicPage: true,
|
||||
mode: 'static',
|
||||
restOpacity: 1
|
||||
})
|
||||
const handleKeyup = (event: KeyboardEvent) => {
|
||||
const down = event.type === 'keydown'
|
||||
input.update(data => {
|
||||
if (event.key === 'w') data.left.y = down ? 1 : 0
|
||||
if (event.key === 'a') data.left.x = down ? 1 : 0
|
||||
if (event.key === 's') data.left.y = down ? -1 : 0
|
||||
if (event.key === 'd') data.left.x = down ? -1 : 0
|
||||
return data
|
||||
})
|
||||
throttle.throttle(updateData, throttle_timing)
|
||||
}
|
||||
|
||||
right = nipplejs.create({
|
||||
zone: document.getElementById('right') as HTMLElement,
|
||||
color: '#15191e80',
|
||||
dynamicPage: true,
|
||||
mode: 'static',
|
||||
restOpacity: 1
|
||||
})
|
||||
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
|
||||
const value: number = Number((event.target as HTMLInputElement).value)
|
||||
|
||||
left.on('move', (_, data) => handleJoyMove('left', data.vector))
|
||||
left.on('end', (_, __) => handleJoyMove('left', { x: 0, y: 0 }))
|
||||
right.on('move', (_, data) => handleJoyMove('right', data.vector))
|
||||
right.on('end', (_, __) => handleJoyMove('right', { x: 0, y: 0 }))
|
||||
})
|
||||
input.update(inputData => {
|
||||
inputData[key] = value
|
||||
return inputData
|
||||
})
|
||||
throttle.throttle(updateData, throttle_timing)
|
||||
}
|
||||
|
||||
const handleJoyMove = (key: 'left' | 'right', data: vector) => {
|
||||
input.update(inputData => {
|
||||
inputData[key] = data
|
||||
return inputData
|
||||
})
|
||||
throttle.throttle(updateData, throttle_timing)
|
||||
}
|
||||
const changeMode = (modeValue: Modes) => {
|
||||
mode.set(modes.indexOf(modeValue))
|
||||
}
|
||||
|
||||
const updateData = () => {
|
||||
data[0] = $input.left.x
|
||||
data[1] = $input.left.y
|
||||
data[2] = $input.right.x
|
||||
data[3] = $input.right.y
|
||||
data[4] = $input.height
|
||||
data[5] = $input.speed
|
||||
data[6] = $input.s1
|
||||
|
||||
outControllerData.set(data)
|
||||
}
|
||||
|
||||
const handleKeyup = (event: KeyboardEvent) => {
|
||||
const down = event.type === 'keydown'
|
||||
input.update(data => {
|
||||
if (event.key === 'w') data.left.y = down ? 1 : 0
|
||||
if (event.key === 'a') data.left.x = down ? 1 : 0
|
||||
if (event.key === 's') data.left.y = down ? -1 : 0
|
||||
if (event.key === 'd') data.left.x = down ? -1 : 0
|
||||
return data
|
||||
})
|
||||
throttle.throttle(updateData, throttle_timing)
|
||||
}
|
||||
|
||||
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => {
|
||||
const value: number = Number((event.target as HTMLInputElement).value)
|
||||
|
||||
input.update(inputData => {
|
||||
inputData[key] = value
|
||||
return inputData
|
||||
})
|
||||
throttle.throttle(updateData, throttle_timing)
|
||||
}
|
||||
|
||||
const changeMode = (modeValue: Modes) => {
|
||||
mode.set(modes.indexOf(modeValue))
|
||||
}
|
||||
|
||||
const changeWalkGait = (walkGaitValue: WalkGaits) => {
|
||||
walkGait.set(walkGaitValue)
|
||||
}
|
||||
const changeWalkGait = (walkGaitValue: WalkGaits) => {
|
||||
walkGait.set(walkGaitValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="absolute top-0 left-0 w-screen h-screen">
|
||||
<div class="absolute top-0 left-0 h-full w-full flex portrait:hidden">
|
||||
<div id="left" class="flex w-60 items-center justify-end"></div>
|
||||
<div class="flex-1"></div>
|
||||
<div id="right" class="flex w-60 items-center"></div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex">
|
||||
<div class="flex justify-center w-full">
|
||||
<kbd class="kbd">W</kbd>
|
||||
<div class="absolute top-0 left-0 h-full w-full flex portrait:hidden">
|
||||
<div id="left" class="flex w-60 items-center justify-end"></div>
|
||||
<div class="flex-1"></div>
|
||||
<div id="right" class="flex w-60 items-center"></div>
|
||||
</div>
|
||||
<div class="flex justify-center gap-2 w-full">
|
||||
<kbd class="kbd">A</kbd>
|
||||
<kbd class="kbd">S</kbd>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex">
|
||||
<div class="flex justify-center w-full">
|
||||
<kbd class="kbd">W</kbd>
|
||||
</div>
|
||||
<div class="flex justify-center gap-2 w-full">
|
||||
<kbd class="kbd">A</kbd>
|
||||
<kbd class="kbd">S</kbd>
|
||||
<kbd class="kbd">D</kbd>
|
||||
</div>
|
||||
<div class="flex justify-center w-full"></div>
|
||||
</div>
|
||||
<div class="flex justify-center w-full"></div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 z-10 flex items-end">
|
||||
<div class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl">
|
||||
<VerticalSlider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
oninput={(e: Event) => handleRange(e, 'height')} />
|
||||
<label for="height">Ht</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden">
|
||||
<div class="join">
|
||||
{#each modes as modeValue}
|
||||
<button
|
||||
class="btn join-item"
|
||||
class:btn-primary={$mode === modes.indexOf(modeValue)}
|
||||
onclick={() => changeMode(modeValue)}>
|
||||
{capitalize(modeValue)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="absolute bottom-0 z-10 flex items-end">
|
||||
<div
|
||||
class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl"
|
||||
>
|
||||
<VerticalSlider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
oninput={(e: Event) => handleRange(e, 'height')}
|
||||
/>
|
||||
<label for="height">Ht</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden"
|
||||
>
|
||||
<div class="join">
|
||||
{#each modes as modeValue}
|
||||
<button
|
||||
class="btn join-item"
|
||||
class:btn-primary={$mode === modes.indexOf(modeValue)}
|
||||
onclick={() => changeMode(modeValue)}
|
||||
>
|
||||
{capitalize(modeValue)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if $mode === ModesEnum.Walk}
|
||||
<div class="join">
|
||||
{#each Object.values(WalkGaits) as gaitValue}
|
||||
{#if typeof gaitValue === 'number'}
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
class:btn-secondary={$walkGait === gaitValue}
|
||||
onclick={() => changeWalkGait(gaitValue)}>
|
||||
{walkGaitLabels[gaitValue]}
|
||||
</button>
|
||||
{#if $mode === ModesEnum.Walk}
|
||||
<div class="join">
|
||||
{#each Object.values(WalkGaits) as gaitValue}
|
||||
{#if typeof gaitValue === 'number'}
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
class:btn-secondary={$walkGait === gaitValue}
|
||||
onclick={() => changeWalkGait(gaitValue)}
|
||||
>
|
||||
{walkGaitLabels[gaitValue]}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<label for="s1">S1</label>
|
||||
<input
|
||||
type="range"
|
||||
name="s1"
|
||||
min="0"
|
||||
step="0.01"
|
||||
max="1"
|
||||
oninput={e => handleRange(e, 's1')}
|
||||
class="range range-sm range-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="speed">Speed</label>
|
||||
<input
|
||||
type="range"
|
||||
name="speed"
|
||||
min="0"
|
||||
step="0.01"
|
||||
max="1"
|
||||
oninput={e => handleRange(e, 'speed')}
|
||||
class="range range-sm range-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<label for="s1">S1</label>
|
||||
<input
|
||||
type="range"
|
||||
name="s1"
|
||||
min="0"
|
||||
step="0.01"
|
||||
max="1"
|
||||
oninput={e => handleRange(e, 's1')}
|
||||
class="range range-sm range-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="speed">Speed</label>
|
||||
<input
|
||||
type="range"
|
||||
name="speed"
|
||||
min="0"
|
||||
step="0.01"
|
||||
max="1"
|
||||
oninput={e => handleRange(e, 'speed')}
|
||||
class="range range-sm range-primary" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:window onkeyup={handleKeyup} onkeydown={handleKeyup} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageLoad } from './$types'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
export const load = (async () => {
|
||||
goto('/');
|
||||
return;
|
||||
}) satisfies PageLoad;
|
||||
goto('/')
|
||||
return
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Camera from './Camera.svelte';
|
||||
import Camera from './Camera.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<Camera />
|
||||
<Camera />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'Camera'
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
title: 'Camera'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import SettingsCard from "$lib/components/SettingsCard.svelte";
|
||||
import CameraSetting from './CameraSetting.svelte';
|
||||
import Stream from '$lib/components/Stream.svelte';
|
||||
import { Camera } from "$lib/components/icons";
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import CameraSetting from './CameraSetting.svelte'
|
||||
import Stream from '$lib/components/Stream.svelte'
|
||||
import { Camera } from '$lib/components/icons'
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<Camera class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
<Camera class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span >Camera</span>
|
||||
{/snippet}
|
||||
<span>Camera</span>
|
||||
{/snippet}
|
||||
<Stream />
|
||||
<CameraSetting />
|
||||
</SettingsCard>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import type { CameraSettings } from '$lib/types/models';
|
||||
let settings:CameraSettings = $state()
|
||||
import { api } from '$lib/api'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import type { CameraSettings } from '$lib/types/models'
|
||||
let settings: CameraSettings = $state()
|
||||
|
||||
const getCameraSettings = async () => {
|
||||
const result = await api.get<CameraSettings>('/api/camera/settings')
|
||||
if (result.isErr()){
|
||||
console.error("An error occurred", result.inner);
|
||||
if (result.isErr()) {
|
||||
console.error('An error occurred', result.inner)
|
||||
return
|
||||
}
|
||||
settings = result.inner
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
const updateCameraSettings = async () => {
|
||||
const result = await api.post<CameraSettings>('/api/camera/settings', settings)
|
||||
if (result.isErr()){
|
||||
console.error("An error occurred", result.inner);
|
||||
if (result.isErr()) {
|
||||
console.error('An error occurred', result.inner)
|
||||
return
|
||||
}
|
||||
settings = result.inner
|
||||
@@ -25,27 +25,47 @@
|
||||
|
||||
{#await getCameraSettings()}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
{:then _}
|
||||
<div class="flex flex-col gap-1">
|
||||
<button class="btn btn-primary" type="button" onclick={updateCameraSettings}>Update camera settings</button>
|
||||
<button class="btn btn-primary" type="button" onclick={updateCameraSettings}
|
||||
>Update camera settings</button
|
||||
>
|
||||
|
||||
<label for="brightness">
|
||||
Brightness {settings.brightness}
|
||||
<input type="range" min="-2" max="2" class="range range-xs" bind:value={settings.brightness}/>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
class="range range-xs"
|
||||
bind:value={settings.brightness}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="contrast">
|
||||
Contrast {settings.contrast}
|
||||
<input type="range" min="-2" max="2" class="range range-xs" bind:value={settings.contrast}/>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
class="range range-xs"
|
||||
bind:value={settings.contrast}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="framesize">
|
||||
FrameSize {settings.framesize}
|
||||
<input type="range" min="0" max="10" class="range range-xs" bind:value={settings.framesize}/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
class="range range-xs"
|
||||
bind:value={settings.framesize}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="cursor-pointer flex items-center justify-between">
|
||||
Vertical flip
|
||||
Vertical flip
|
||||
<input type="checkbox" class="toggle" bind:checked={settings.vflip} />
|
||||
</label>
|
||||
|
||||
@@ -56,7 +76,10 @@
|
||||
|
||||
<label for="special_effect" class="flex items-center">
|
||||
<span class="basis-1/2">Special Effect</span>
|
||||
<select class="select select-bordered select-sm w-full max-w-xs" bind:value={settings.special_effect}>
|
||||
<select
|
||||
class="select select-bordered select-sm w-full max-w-xs"
|
||||
bind:value={settings.special_effect}
|
||||
>
|
||||
<option value={0}>No effect</option>
|
||||
<option value={1}>Negative</option>
|
||||
<option value={2}>Grayscale</option>
|
||||
@@ -67,4 +90,4 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/await}
|
||||
{/await}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import I2C from './i2c.svelte'
|
||||
import I2C from './i2c.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<I2C />
|
||||
<I2C />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'I2C'
|
||||
}
|
||||
return {
|
||||
title: 'I2C'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
<script lang="ts">
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic, type I2CDevice } from '$lib/types/models'
|
||||
import { Connection } from '$lib/components/icons'
|
||||
import I2CSetting from './i2cSetting.svelte'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic, type I2CDevice } from '$lib/types/models'
|
||||
import { Connection } from '$lib/components/icons'
|
||||
import I2CSetting from './i2cSetting.svelte'
|
||||
|
||||
const i2cDevices = [
|
||||
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
|
||||
{ address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
|
||||
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
|
||||
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
|
||||
{
|
||||
address: 104,
|
||||
part_number: 'MPU6050',
|
||||
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
|
||||
},
|
||||
{ address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
|
||||
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
|
||||
]
|
||||
const i2cDevices = [
|
||||
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
|
||||
{ address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
|
||||
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
|
||||
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
|
||||
{
|
||||
address: 104,
|
||||
part_number: 'MPU6050',
|
||||
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
|
||||
},
|
||||
{ address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
|
||||
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
|
||||
]
|
||||
|
||||
let active_devices: I2CDevice[] = $state([])
|
||||
let active_devices: I2CDevice[] = $state([])
|
||||
|
||||
let isLoading = $state(false)
|
||||
let isLoading = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.i2cScan, handleScan)
|
||||
triggerScan()
|
||||
return () => socket.off(MessageTopic.i2cScan, handleScan)
|
||||
})
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.i2cScan, handleScan)
|
||||
triggerScan()
|
||||
return () => socket.off(MessageTopic.i2cScan, handleScan)
|
||||
})
|
||||
|
||||
const handleScan = (data: any) => {
|
||||
active_devices = data.addresses.map(
|
||||
(address: number) =>
|
||||
i2cDevices.find(device => device.address === address) || {
|
||||
address,
|
||||
part_number: 'Unknown',
|
||||
name: 'Unknown'
|
||||
}
|
||||
)
|
||||
isLoading = false
|
||||
}
|
||||
const handleScan = (data: any) => {
|
||||
active_devices = data.addresses.map(
|
||||
(address: number) =>
|
||||
i2cDevices.find(device => device.address === address) || {
|
||||
address,
|
||||
part_number: 'Unknown',
|
||||
name: 'Unknown'
|
||||
}
|
||||
)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
const triggerScan = () => {
|
||||
isLoading = true
|
||||
socket.sendEvent(MessageTopic.i2cScan, '')
|
||||
}
|
||||
const triggerScan = () => {
|
||||
isLoading = true
|
||||
socket.sendEvent(MessageTopic.i2cScan, '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>I<sup>2</sup>C</span>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-ring loading-xs"></span>
|
||||
{:else}
|
||||
Scan
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet icon()}
|
||||
<Connection class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>I<sup>2</sup>C</span>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-ring loading-xs"></span>
|
||||
{:else}
|
||||
Scan
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<I2CSetting />
|
||||
<I2CSetting />
|
||||
|
||||
<div class="grid">
|
||||
{#if active_devices.length === 0}
|
||||
<div>No I2C devices found</div>
|
||||
{:else}
|
||||
{#each active_devices as device}
|
||||
<div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#if active_devices.length === 0}
|
||||
<div>No I2C devices found</div>
|
||||
{:else}
|
||||
{#each active_devices as device}
|
||||
<div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,99 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { Cancel, Edit, EditOff, Power } from '$lib/components/icons'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models'
|
||||
import { onMount } from 'svelte'
|
||||
import { modals } from 'svelte-modals'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
import { Cancel, Edit, EditOff, Power } from '$lib/components/icons'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models'
|
||||
import { onMount } from 'svelte'
|
||||
import { modals } from 'svelte-modals'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
|
||||
let settings: PeripheralsConfiguration | null = $state(null)
|
||||
let isEditing = $state(false)
|
||||
let settings: PeripheralsConfiguration | null = $state(null)
|
||||
let isEditing = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.peripheralSettings, handleSettings)
|
||||
socket.sendEvent(MessageTopic.peripheralSettings, '')
|
||||
return () => socket.off(MessageTopic.peripheralSettings, handleSettings)
|
||||
})
|
||||
|
||||
const handleSettings = (data: any) => {
|
||||
settings = data
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm configuration',
|
||||
message:
|
||||
'Are you sure you want to save this configuration? The operation cannot be undone. Please make sure you have the correct settings.',
|
||||
labels: {
|
||||
cancel: { label: 'Cancel', icon: Cancel },
|
||||
confirm: { label: 'Confirm', icon: Power }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
socket.sendEvent(MessageTopic.peripheralSettings, settings)
|
||||
}
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.peripheralSettings, handleSettings)
|
||||
socket.sendEvent(MessageTopic.peripheralSettings, '')
|
||||
return () => socket.off(MessageTopic.peripheralSettings, handleSettings)
|
||||
})
|
||||
}
|
||||
|
||||
const Icon = $derived(isEditing ? EditOff : Edit)
|
||||
const handleSettings = (data: any) => {
|
||||
settings = data
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm configuration',
|
||||
message:
|
||||
'Are you sure you want to save this configuration? The operation cannot be undone. Please make sure you have the correct settings.',
|
||||
labels: {
|
||||
cancel: { label: 'Cancel', icon: Cancel },
|
||||
confirm: { label: 'Confirm', icon: Power }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
socket.sendEvent(MessageTopic.peripheralSettings, settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const Icon = $derived(isEditing ? EditOff : Edit)
|
||||
</script>
|
||||
|
||||
{#if settings}
|
||||
<div class="collapse bg-base-100 border-base-300 border">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title font-semibold">Configuration</div>
|
||||
<div class="collapse-content text-sm">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="sda" class="input validator">
|
||||
SDA
|
||||
<div class="collapse bg-base-100 border-base-300 border">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title font-semibold">Configuration</div>
|
||||
<div class="collapse-content text-sm">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="sda" class="input validator">
|
||||
SDA
|
||||
|
||||
<input
|
||||
id="sda"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Type a number between 1 to 48"
|
||||
min="0"
|
||||
max="48"
|
||||
title="SDA pin number (0-48)"
|
||||
disabled={!isEditing}
|
||||
bind:value={settings.sda} />
|
||||
</label>
|
||||
<label for="scl" class="input validator">
|
||||
SCL
|
||||
<input
|
||||
id="sda"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Type a number between 1 to 48"
|
||||
min="0"
|
||||
max="48"
|
||||
title="SDA pin number (0-48)"
|
||||
disabled={!isEditing}
|
||||
bind:value={settings.sda}
|
||||
/>
|
||||
</label>
|
||||
<label for="scl" class="input validator">
|
||||
SCL
|
||||
|
||||
<input
|
||||
id="scl"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Type a number between 1 to 48"
|
||||
min="1"
|
||||
max="48"
|
||||
title="SCL pin number (0-48)"
|
||||
disabled={!isEditing}
|
||||
bind:value={settings.scl} />
|
||||
</label>
|
||||
<label class="input validator" for="frequency">
|
||||
Frequency
|
||||
<input
|
||||
id="frequency"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Type a number between 100000 to 430000"
|
||||
min="100000"
|
||||
max="430000"
|
||||
title="I2C frequency in Hz"
|
||||
disabled={!isEditing}
|
||||
bind:value={settings.frequency} />
|
||||
</label>
|
||||
<div>
|
||||
<button class="btn btn-outline btn-primary" onclick={() => (isEditing = !isEditing)}>
|
||||
<Icon class="h-6 w-6" />
|
||||
</button>
|
||||
{#if isEditing}
|
||||
<button class="btn btn-outline btn-primary" onclick={handleSave}>Save</button>
|
||||
{/if}
|
||||
<input
|
||||
id="scl"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Type a number between 1 to 48"
|
||||
min="1"
|
||||
max="48"
|
||||
title="SCL pin number (0-48)"
|
||||
disabled={!isEditing}
|
||||
bind:value={settings.scl}
|
||||
/>
|
||||
</label>
|
||||
<label class="input validator" for="frequency">
|
||||
Frequency
|
||||
<input
|
||||
id="frequency"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Type a number between 100000 to 430000"
|
||||
min="100000"
|
||||
max="430000"
|
||||
title="I2C frequency in Hz"
|
||||
disabled={!isEditing}
|
||||
bind:value={settings.frequency}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-outline btn-primary"
|
||||
onclick={() => (isEditing = !isEditing)}
|
||||
>
|
||||
<Icon class="h-6 w-6" />
|
||||
</button>
|
||||
{#if isEditing}
|
||||
<button class="btn btn-outline btn-primary" onclick={handleSave}
|
||||
>Save</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import IMU from './imu.svelte';
|
||||
import IMU from './imu.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<IMU />
|
||||
<IMU />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'IMU'
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
title: 'IMU'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,253 +1,256 @@
|
||||
<script lang="ts">
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { imu } from '$lib/stores/imu'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic, type IMU } from '$lib/types/models'
|
||||
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||
import { Rotate3d } from '$lib/components/icons'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { imu } from '$lib/stores/imu'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic, type IMU } from '$lib/types/models'
|
||||
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||
import { Rotate3d } from '$lib/components/icons'
|
||||
|
||||
Chart.register(...registerables)
|
||||
Chart.register(...registerables)
|
||||
|
||||
const features = useFeatureFlags()
|
||||
let intervalId: ReturnType<typeof setInterval> | number
|
||||
const features = useFeatureFlags()
|
||||
let intervalId: ReturnType<typeof setInterval> | number
|
||||
|
||||
let angleChartElement: HTMLCanvasElement
|
||||
let tempChartElement: HTMLCanvasElement
|
||||
let altitudeChartElement: HTMLCanvasElement
|
||||
let angleChartElement: HTMLCanvasElement
|
||||
let tempChartElement: HTMLCanvasElement
|
||||
let altitudeChartElement: HTMLCanvasElement
|
||||
|
||||
let angleChart: Chart
|
||||
let tempChart: Chart
|
||||
let altitudeChart: Chart
|
||||
let angleChart: Chart
|
||||
let tempChart: Chart
|
||||
let altitudeChart: Chart
|
||||
|
||||
const getChartColors = () => {
|
||||
const style = getComputedStyle(document.body)
|
||||
return {
|
||||
primary: style.getPropertyValue('--color-primary'),
|
||||
secondary: style.getPropertyValue('--color-secondary'),
|
||||
accent: style.getPropertyValue('--color-accent'),
|
||||
background: style.getPropertyValue('--color-background')
|
||||
}
|
||||
}
|
||||
|
||||
const createBaseChartConfig = (bgColor: string) => ({
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
tooltip: { mode: 'index' as const, intersect: false }
|
||||
},
|
||||
elements: { point: { radius: 1 } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: bgColor },
|
||||
ticks: { color: bgColor },
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
position: 'left' as const,
|
||||
min: 0,
|
||||
max: 10,
|
||||
grid: { color: bgColor },
|
||||
ticks: { color: bgColor },
|
||||
border: { color: bgColor }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const initializeCharts = () => {
|
||||
const colors = getChartColors()
|
||||
const baseConfig = createBaseChartConfig(colors.background)
|
||||
|
||||
angleChart = new Chart(angleChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'x',
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
data: $imu.x,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'y',
|
||||
borderColor: colors.secondary,
|
||||
backgroundColor: colors.secondary,
|
||||
borderWidth: 2,
|
||||
data: $imu.y,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'z',
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accent,
|
||||
borderWidth: 2,
|
||||
data: $imu.z,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...baseConfig,
|
||||
scales: {
|
||||
...baseConfig.scales,
|
||||
y: {
|
||||
...baseConfig.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Angle [°]',
|
||||
color: colors.background,
|
||||
font: { size: 16, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
const getChartColors = () => {
|
||||
const style = getComputedStyle(document.body)
|
||||
return {
|
||||
primary: style.getPropertyValue('--color-primary'),
|
||||
secondary: style.getPropertyValue('--color-secondary'),
|
||||
accent: style.getPropertyValue('--color-accent'),
|
||||
background: style.getPropertyValue('--color-background')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tempChart = new Chart(tempChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Barometer temperature',
|
||||
borderColor: colors.secondary,
|
||||
backgroundColor: colors.secondary,
|
||||
borderWidth: 2,
|
||||
data: $imu.bmp_temp,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...baseConfig,
|
||||
scales: {
|
||||
...baseConfig.scales,
|
||||
y: {
|
||||
...baseConfig.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Temperature [C°]',
|
||||
color: colors.background,
|
||||
font: { size: 16, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
altitudeChart = new Chart(altitudeChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Altitude',
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
data: $imu.altitude,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...baseConfig,
|
||||
scales: {
|
||||
...baseConfig.scales,
|
||||
y: {
|
||||
...baseConfig.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Altitude [M]',
|
||||
color: colors.background,
|
||||
font: { size: 16, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateChartData = (chart: Chart, data: number[], label: string) => {
|
||||
chart.data.labels = data
|
||||
chart.data.datasets[0].data = data
|
||||
chart.options.scales!.y!.min = Math.min(...data) - 1
|
||||
chart.options.scales!.y!.max = Math.max(...data) + 1
|
||||
chart.update('none')
|
||||
}
|
||||
|
||||
const updateData = () => {
|
||||
if ($features.imu) {
|
||||
angleChart.data.labels = $imu.x
|
||||
angleChart.data.datasets[0].data = $imu.x
|
||||
angleChart.data.datasets[1].data = $imu.y
|
||||
angleChart.data.datasets[2].data = $imu.z
|
||||
|
||||
const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
|
||||
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
|
||||
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
|
||||
angleChart.update('none')
|
||||
}
|
||||
|
||||
if ($features.bmp) {
|
||||
updateChartData(tempChart, $imu.bmp_temp, 'Temperature')
|
||||
updateChartData(altitudeChart, $imu.altitude, 'Altitude')
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.imu, (data: IMU) => {
|
||||
console.log(data)
|
||||
imu.addData(data)
|
||||
const createBaseChartConfig = (bgColor: string) => ({
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
tooltip: { mode: 'index' as const, intersect: false }
|
||||
},
|
||||
elements: { point: { radius: 1 } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: bgColor },
|
||||
ticks: { color: bgColor },
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
position: 'left' as const,
|
||||
min: 0,
|
||||
max: 10,
|
||||
grid: { color: bgColor },
|
||||
ticks: { color: bgColor },
|
||||
border: { color: bgColor }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
initializeCharts()
|
||||
intervalId = setInterval(updateData, 200)
|
||||
})
|
||||
const initializeCharts = () => {
|
||||
const colors = getChartColors()
|
||||
const baseConfig = createBaseChartConfig(colors.background)
|
||||
|
||||
onDestroy(() => {
|
||||
socket.off(MessageTopic.imu)
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
angleChart = new Chart(angleChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'x',
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
data: $imu.x,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'y',
|
||||
borderColor: colors.secondary,
|
||||
backgroundColor: colors.secondary,
|
||||
borderWidth: 2,
|
||||
data: $imu.y,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'z',
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accent,
|
||||
borderWidth: 2,
|
||||
data: $imu.z,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...baseConfig,
|
||||
scales: {
|
||||
...baseConfig.scales,
|
||||
y: {
|
||||
...baseConfig.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Angle [°]',
|
||||
color: colors.background,
|
||||
font: { size: 16, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tempChart = new Chart(tempChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Barometer temperature',
|
||||
borderColor: colors.secondary,
|
||||
backgroundColor: colors.secondary,
|
||||
borderWidth: 2,
|
||||
data: $imu.bmp_temp,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...baseConfig,
|
||||
scales: {
|
||||
...baseConfig.scales,
|
||||
y: {
|
||||
...baseConfig.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Temperature [C°]',
|
||||
color: colors.background,
|
||||
font: { size: 16, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
altitudeChart = new Chart(altitudeChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Altitude',
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
data: $imu.altitude,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...baseConfig,
|
||||
scales: {
|
||||
...baseConfig.scales,
|
||||
y: {
|
||||
...baseConfig.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Altitude [M]',
|
||||
color: colors.background,
|
||||
font: { size: 16, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateChartData = (chart: Chart, data: number[], label: string) => {
|
||||
chart.data.labels = data
|
||||
chart.data.datasets[0].data = data
|
||||
chart.options.scales!.y!.min = Math.min(...data) - 1
|
||||
chart.options.scales!.y!.max = Math.max(...data) + 1
|
||||
chart.update('none')
|
||||
}
|
||||
|
||||
const updateData = () => {
|
||||
if ($features.imu) {
|
||||
angleChart.data.labels = $imu.x
|
||||
angleChart.data.datasets[0].data = $imu.x
|
||||
angleChart.data.datasets[1].data = $imu.y
|
||||
angleChart.data.datasets[2].data = $imu.z
|
||||
|
||||
const allValues = [...$imu.x, ...$imu.y, ...$imu.z]
|
||||
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
|
||||
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
|
||||
angleChart.update('none')
|
||||
}
|
||||
|
||||
if ($features.bmp) {
|
||||
updateChartData(tempChart, $imu.bmp_temp, 'Temperature')
|
||||
updateChartData(altitudeChart, $imu.altitude, 'Altitude')
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
socket.on(MessageTopic.imu, (data: IMU) => {
|
||||
console.log(data)
|
||||
imu.addData(data)
|
||||
})
|
||||
|
||||
initializeCharts()
|
||||
intervalId = setInterval(updateData, 200)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
socket.off(MessageTopic.imu)
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>IMU</span>
|
||||
{/snippet}
|
||||
{#snippet icon()}
|
||||
<Rotate3d class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>IMU</span>
|
||||
{/snippet}
|
||||
|
||||
{#if $features.imu}
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<canvas bind:this={angleChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $features.imu}
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<canvas bind:this={angleChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $features.bmp}
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<canvas bind:this={tempChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<canvas bind:this={altitudeChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $features.bmp}
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<canvas bind:this={tempChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<canvas bind:this={altitudeChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Servos from './servos.svelte'
|
||||
import ServoTable from './ServoTable.svelte'
|
||||
import Servos from './servos.svelte'
|
||||
import ServoTable from './ServoTable.svelte'
|
||||
|
||||
let servoId = $state(0)
|
||||
let pwm = $state(306)
|
||||
let servoId = $state(0)
|
||||
let pwm = $state(306)
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<Servos bind:servoId bind:pwm />
|
||||
<ServoTable {servoId} {pwm} />
|
||||
<Servos bind:servoId bind:pwm />
|
||||
<ServoTable {servoId} {pwm} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'Servo'
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
title: 'Servo'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,113 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api'
|
||||
import { onMount } from 'svelte'
|
||||
import { RotateCw, RotateCcw } from '$lib/components/icons'
|
||||
interface Props {
|
||||
data?: any
|
||||
servoId?: number
|
||||
pwm?: number
|
||||
}
|
||||
|
||||
let {
|
||||
data = $bindable({
|
||||
servos: []
|
||||
}),
|
||||
pwm = $bindable(306),
|
||||
servoId = $bindable(0)
|
||||
}: Props = $props()
|
||||
|
||||
const updateValue = (event: Event, index: number, key: string) => {
|
||||
data.servos[index][key] = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const syncConfig = async () => {
|
||||
await api.post('/api/servo/config', data)
|
||||
}
|
||||
|
||||
const toggleDirection = async (index: number) => {
|
||||
data.servos[index].direction = data.servos[index].direction === 1 ? -1 : 1
|
||||
await syncConfig()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const result = await api.get('/api/servo/config')
|
||||
if (result.isOk()) {
|
||||
data = result.inner
|
||||
import { api } from '$lib/api'
|
||||
import { onMount } from 'svelte'
|
||||
import { RotateCw, RotateCcw } from '$lib/components/icons'
|
||||
interface Props {
|
||||
data?: any
|
||||
servoId?: number
|
||||
pwm?: number
|
||||
}
|
||||
})
|
||||
|
||||
const setCenterPWM = async () => {
|
||||
console.log('setCenterPWM', servoId, pwm)
|
||||
data.servos[servoId]['center_pwm'] = pwm
|
||||
await syncConfig()
|
||||
}
|
||||
let {
|
||||
data = $bindable({
|
||||
servos: []
|
||||
}),
|
||||
pwm = $bindable(306),
|
||||
servoId = $bindable(0)
|
||||
}: Props = $props()
|
||||
|
||||
const updateValue = (event: Event, index: number, key: string) => {
|
||||
data.servos[index][key] = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const syncConfig = async () => {
|
||||
await api.post('/api/servo/config', data)
|
||||
}
|
||||
|
||||
const toggleDirection = async (index: number) => {
|
||||
data.servos[index].direction = data.servos[index].direction === 1 ? -1 : 1
|
||||
await syncConfig()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const result = await api.get('/api/servo/config')
|
||||
if (result.isOk()) {
|
||||
data = result.inner
|
||||
}
|
||||
})
|
||||
|
||||
const setCenterPWM = async () => {
|
||||
console.log('setCenterPWM', servoId, pwm)
|
||||
data.servos[servoId]['center_pwm'] = pwm
|
||||
await syncConfig()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button>
|
||||
<button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Servo</th>
|
||||
<th>Center PWM</th>
|
||||
<th>Center Angle</th>
|
||||
<th>Direction</th>
|
||||
<th>Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.servos as servo, index}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td class="font-medium">Servo {index}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered w-20"
|
||||
value={servo.center_pwm}
|
||||
onblur={syncConfig}
|
||||
oninput={event => updateValue(event, index, 'center_pwm')}
|
||||
min="80"
|
||||
max="600" />
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
class="input input-sm input-bordered w-20"
|
||||
value={servo.center_angle}
|
||||
onblur={syncConfig}
|
||||
oninput={event => updateValue(event, index, 'center_angle')}
|
||||
min="-90"
|
||||
max="90" />
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Toggle direction {servo.direction}"
|
||||
onclick={() => toggleDirection(index)}>
|
||||
{#if servo.direction === 1}
|
||||
<RotateCw class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-20"
|
||||
value={servo.conversion}
|
||||
onblur={syncConfig}
|
||||
oninput={event => updateValue(event, index, 'conversion')}
|
||||
min="0"
|
||||
max="10" />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Servo</th>
|
||||
<th>Center PWM</th>
|
||||
<th>Center Angle</th>
|
||||
<th>Direction</th>
|
||||
<th>Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.servos as servo, index}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td class="font-medium">Servo {index}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered w-20"
|
||||
value={servo.center_pwm}
|
||||
onblur={syncConfig}
|
||||
oninput={event => updateValue(event, index, 'center_pwm')}
|
||||
min="80"
|
||||
max="600"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
class="input input-sm input-bordered w-20"
|
||||
value={servo.center_angle}
|
||||
onblur={syncConfig}
|
||||
oninput={event => updateValue(event, index, 'center_angle')}
|
||||
min="-90"
|
||||
max="90"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Toggle direction {servo.direction}"
|
||||
onclick={() => toggleDirection(index)}
|
||||
>
|
||||
{#if servo.direction === 1}
|
||||
<RotateCw class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered w-20"
|
||||
value={servo.conversion}
|
||||
onblur={syncConfig}
|
||||
oninput={event => updateValue(event, index, 'conversion')}
|
||||
min="0"
|
||||
max="10"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic } from '$lib/types/models'
|
||||
import { throttler as Throttler } from '$lib/utilities'
|
||||
import { socket } from '$lib/stores'
|
||||
import { MessageTopic } from '$lib/types/models'
|
||||
import { throttler as Throttler } from '$lib/utilities'
|
||||
|
||||
let { servoId = $bindable(0), pwm = $bindable(306) } = $props()
|
||||
let { servoId = $bindable(0), pwm = $bindable(306) } = $props()
|
||||
|
||||
let active = $state(false)
|
||||
let active = $state(false)
|
||||
|
||||
let allServos = $state(false)
|
||||
let allServos = $state(false)
|
||||
|
||||
const throttler = new Throttler()
|
||||
const throttler = new Throttler()
|
||||
|
||||
const activateServo = () => {
|
||||
socket.sendEvent(MessageTopic.servoState, { active: 1 })
|
||||
}
|
||||
const activateServo = () => {
|
||||
socket.sendEvent(MessageTopic.servoState, { active: 1 })
|
||||
}
|
||||
|
||||
const deactivateServo = () => {
|
||||
socket.sendEvent(MessageTopic.servoState, { active: 0 })
|
||||
}
|
||||
const deactivateServo = () => {
|
||||
socket.sendEvent(MessageTopic.servoState, { active: 0 })
|
||||
}
|
||||
|
||||
const updatePWM = () => {
|
||||
throttler.throttle(() => {
|
||||
socket.sendEvent(MessageTopic.servoPWM, { servo_id: servoId, pwm })
|
||||
}, 10)
|
||||
}
|
||||
const updatePWM = () => {
|
||||
throttler.throttle(() => {
|
||||
socket.sendEvent(MessageTopic.servoPWM, { servo_id: servoId, pwm })
|
||||
}, 10)
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
servoId = allServos ? -1 : 0
|
||||
}
|
||||
const toggleMode = () => {
|
||||
servoId = allServos ? -1 : 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-lg">General servo configuration</h2>
|
||||
<span>Servo</span>
|
||||
<span>{pwm}</span>
|
||||
<h2 class="text-lg">General servo configuration</h2>
|
||||
<span>Servo</span>
|
||||
<span>{pwm}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="80"
|
||||
max="600"
|
||||
bind:value={pwm}
|
||||
oninput={updatePWM}
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" />
|
||||
type="range"
|
||||
min="80"
|
||||
max="600"
|
||||
bind:value={pwm}
|
||||
oninput={updatePWM}
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-lg">General servo configuration</h2>
|
||||
<span>
|
||||
<label for="mode">All servoes</label>
|
||||
<input type="checkbox" class="toggle" bind:checked={allServos} onchange={toggleMode} />
|
||||
</span>
|
||||
<span>
|
||||
<label for="active">Active</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
bind:checked={active}
|
||||
onchange={active ? activateServo : deactivateServo} />
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<label for="servoId">Servo active {servoId}</label>
|
||||
<input type="range" min="0" max="11" step="1" bind:value={servoId} />
|
||||
</span>
|
||||
<h2 class="text-lg">General servo configuration</h2>
|
||||
<span>
|
||||
<label for="mode">All servoes</label>
|
||||
<input type="checkbox" class="toggle" bind:checked={allServos} onchange={toggleMode} />
|
||||
</span>
|
||||
<span>
|
||||
<label for="active">Active</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
bind:checked={active}
|
||||
onchange={active ? activateServo : deactivateServo}
|
||||
/>
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<label for="servoId">Servo active {servoId}</label>
|
||||
<input type="range" min="0" max="11" step="1" bind:value={servoId} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -37,20 +37,20 @@
|
||||
<h1 class="text-2xl font-bold">Settings</h1>
|
||||
|
||||
<div class="pt-14 flex h-full">
|
||||
<nav class="w-1/6 flex flex-col">
|
||||
<!-- {#each menu as link} -->
|
||||
<!-- <Link to={'/settings' + link.path}> -->
|
||||
<div class="px-4 py-2 flex gap-2 items-center">
|
||||
<!-- <Icon src={link.icon} size="24" />{link.title} -->
|
||||
</div>
|
||||
<!-- </Link> -->
|
||||
<!-- {/each} -->
|
||||
</nav>
|
||||
<main class="w-full h-full">
|
||||
<!-- <Router> -->
|
||||
<!-- {#each menu as link} -->
|
||||
<!-- <Route path={link.path} component={link.component}></Route> -->
|
||||
<!-- {/each} -->
|
||||
<!-- </Router> -->
|
||||
</main>
|
||||
<nav class="w-1/6 flex flex-col">
|
||||
<!-- {#each menu as link} -->
|
||||
<!-- <Link to={'/settings' + link.path}> -->
|
||||
<div class="px-4 py-2 flex gap-2 items-center">
|
||||
<!-- <Icon src={link.icon} size="24" />{link.title} -->
|
||||
</div>
|
||||
<!-- </Link> -->
|
||||
<!-- {/each} -->
|
||||
</nav>
|
||||
<main class="w-full h-full">
|
||||
<!-- <Router> -->
|
||||
<!-- {#each menu as link} -->
|
||||
<!-- <Route path={link.path} component={link.component}></Route> -->
|
||||
<!-- {/each} -->
|
||||
<!-- </Router> -->
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageLoad } from './$types'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
export const load = (async () => {
|
||||
goto('/');
|
||||
return;
|
||||
}) satisfies PageLoad;
|
||||
goto('/')
|
||||
return
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import FileSystem from './FileSystem.svelte'
|
||||
import FileSystem from './FileSystem.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<FileSystem />
|
||||
<FileSystem />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return { title: 'File System' }
|
||||
return { title: 'File System' }
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { FileIcon, TrashIcon } from '$lib/components/icons'
|
||||
import { FileIcon, TrashIcon } from '$lib/components/icons'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
selected: (name: string) => void
|
||||
onDelete: (name: string) => void
|
||||
}
|
||||
interface Props {
|
||||
name: string
|
||||
selected: (name: string) => void
|
||||
onDelete: (name: string) => void
|
||||
}
|
||||
|
||||
let { name, selected, onDelete }: Props = $props()
|
||||
let { name, selected, onDelete }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center pl-4 group hover:bg-gray-700 rounded py-1">
|
||||
<button class="flex items-center gap-2 flex-grow" onclick={() => selected(name)}>
|
||||
<FileIcon class="w-4 h-4" />
|
||||
<span class="text-sm">{name}</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-2 flex-grow" onclick={() => selected(name)}>
|
||||
<FileIcon class="w-4 h-4" />
|
||||
<span class="text-sm">{name}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 hover:text-red-500"
|
||||
onclick={() => onDelete(name)}>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 hover:text-red-500"
|
||||
onclick={() => onDelete(name)}
|
||||
>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
<script lang="ts">
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import Folder from './Folder.svelte'
|
||||
import { api } from '$lib/api'
|
||||
import type { Directory } from '$lib/types/models'
|
||||
import { FolderIcon, Add, FileIcon } from '$lib/components/icons'
|
||||
import { modals } from 'svelte-modals'
|
||||
import NewFolderDialog from './NewFolderDialog.svelte'
|
||||
import NewFileDialog from './NewFileDialog.svelte'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import Folder from './Folder.svelte'
|
||||
import { api } from '$lib/api'
|
||||
import type { Directory } from '$lib/types/models'
|
||||
import { FolderIcon, Add, FileIcon } from '$lib/components/icons'
|
||||
import { modals } from 'svelte-modals'
|
||||
import NewFolderDialog from './NewFolderDialog.svelte'
|
||||
import NewFileDialog from './NewFileDialog.svelte'
|
||||
|
||||
let filename = $state('')
|
||||
let content = $state('')
|
||||
let isEditing = $state(false)
|
||||
let filename = $state('')
|
||||
let content = $state('')
|
||||
let isEditing = $state(false)
|
||||
|
||||
const getFiles = async () => {
|
||||
const result = await api.get<Directory>('/api/files')
|
||||
if (result.isOk()) {
|
||||
return result.inner
|
||||
const getFiles = async () => {
|
||||
const result = await api.get<Directory>('/api/files')
|
||||
if (result.isOk()) {
|
||||
return result.inner
|
||||
}
|
||||
return { root: {} }
|
||||
}
|
||||
return { root: {} }
|
||||
}
|
||||
|
||||
const getContent = async (name: string) => {
|
||||
if (!name) return ''
|
||||
const result = await api.get(`/api/config/${name}`)
|
||||
if (result.isOk()) {
|
||||
content = JSON.stringify(result.inner, null, 4)
|
||||
return content
|
||||
const getContent = async (name: string) => {
|
||||
if (!name) return ''
|
||||
const result = await api.get(`/api/config/${name}`)
|
||||
if (result.isOk()) {
|
||||
content = JSON.stringify(result.inner, null, 4)
|
||||
return content
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const saveContent = async () => {
|
||||
if (!filename) return
|
||||
const result = await api.post('/api/files/edit', {
|
||||
file: '/config/' + filename,
|
||||
content
|
||||
})
|
||||
if (result.isOk()) {
|
||||
isEditing = false
|
||||
const saveContent = async () => {
|
||||
if (!filename) return
|
||||
const result = await api.post('/api/files/edit', {
|
||||
file: '/config/' + filename,
|
||||
content
|
||||
})
|
||||
if (result.isOk()) {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = async (name: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${name}?`)) return
|
||||
const result = await api.post('/api/files/delete', { file: '/config/' + name })
|
||||
if (result.isOk()) {
|
||||
filename = ''
|
||||
content = ''
|
||||
const deleteFile = async (name: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${name}?`)) return
|
||||
const result = await api.post('/api/files/delete', { file: '/config/' + name })
|
||||
if (result.isOk()) {
|
||||
filename = ''
|
||||
content = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createFolder = async (folderName: string) => {
|
||||
if (!folderName) return
|
||||
const result = await api.post('/api/files/mkdir', {
|
||||
path: '/config/' + folderName
|
||||
})
|
||||
if (result.isOk()) {
|
||||
// Refresh the file list
|
||||
await getFiles()
|
||||
const createFolder = async (folderName: string) => {
|
||||
if (!folderName) return
|
||||
const result = await api.post('/api/files/mkdir', {
|
||||
path: '/config/' + folderName
|
||||
})
|
||||
if (result.isOk()) {
|
||||
// Refresh the file list
|
||||
await getFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelected = async (name: string) => {
|
||||
filename = name
|
||||
isEditing = false
|
||||
await getContent(name)
|
||||
}
|
||||
|
||||
const openNewFolderDialog = () => {
|
||||
modals.open(NewFolderDialog, {
|
||||
onConfirm: createFolder
|
||||
})
|
||||
}
|
||||
|
||||
const createFile = async (fileName: string) => {
|
||||
if (!fileName) return
|
||||
const result = await api.post('/api/files/edit', {
|
||||
file: '/config/' + fileName,
|
||||
content: '{}' // Default empty JSON object
|
||||
})
|
||||
if (result.isOk()) {
|
||||
// Refresh the file list and select the new file
|
||||
await getFiles()
|
||||
await updateSelected(fileName)
|
||||
const updateSelected = async (name: string) => {
|
||||
filename = name
|
||||
isEditing = false
|
||||
await getContent(name)
|
||||
}
|
||||
}
|
||||
|
||||
const openNewFileDialog = () => {
|
||||
modals.open(NewFileDialog, {
|
||||
onConfirm: createFile
|
||||
})
|
||||
}
|
||||
const openNewFolderDialog = () => {
|
||||
modals.open(NewFolderDialog, {
|
||||
onConfirm: createFolder
|
||||
})
|
||||
}
|
||||
|
||||
const createFile = async (fileName: string) => {
|
||||
if (!fileName) return
|
||||
const result = await api.post('/api/files/edit', {
|
||||
file: '/config/' + fileName,
|
||||
content: '{}' // Default empty JSON object
|
||||
})
|
||||
if (result.isOk()) {
|
||||
// Refresh the file list and select the new file
|
||||
await getFiles()
|
||||
await updateSelected(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
const openNewFileDialog = () => {
|
||||
modals.open(NewFileDialog, {
|
||||
onConfirm: createFile
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <SettingsCard collapsible={false}> -->
|
||||
@@ -100,73 +100,84 @@
|
||||
<!-- {/snippet}
|
||||
{#snippet title()} -->
|
||||
<div class="flex justify-between items-center w-full gap-2">
|
||||
<span>File System</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
|
||||
<FileIcon class="w-4 h-4" />
|
||||
New File
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFolderDialog}>
|
||||
<Add class="w-4 h-4" />
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
<span>File System</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
|
||||
<FileIcon class="w-4 h-4" />
|
||||
New File
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary flex items-center gap-2"
|
||||
onclick={openNewFolderDialog}
|
||||
>
|
||||
<Add class="w-4 h-4" />
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- {/snippet} -->
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4 w-full">
|
||||
<!-- File Tree -->
|
||||
<div
|
||||
class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4">
|
||||
{#await getFiles()}
|
||||
<Spinner />
|
||||
{:then files}
|
||||
<Folder
|
||||
name="/"
|
||||
files={files.root}
|
||||
expanded
|
||||
selected={updateSelected}
|
||||
onDelete={deleteFile} />
|
||||
{/await}
|
||||
</div>
|
||||
<!-- File Tree -->
|
||||
<div
|
||||
class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4"
|
||||
>
|
||||
{#await getFiles()}
|
||||
<Spinner />
|
||||
{:then files}
|
||||
<Folder
|
||||
name="/"
|
||||
files={files.root}
|
||||
expanded
|
||||
selected={updateSelected}
|
||||
onDelete={deleteFile}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<!-- File Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if filename}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2">
|
||||
<h3 class="text-lg font-semibold truncate">{filename}</h3>
|
||||
<div class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<button class="btn btn-sm btn-primary" onclick={saveContent}>Save</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick={() => (isEditing = false)}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick={() => deleteFile(filename)}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- File Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if filename}
|
||||
<div
|
||||
class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2"
|
||||
>
|
||||
<h3 class="text-lg font-semibold truncate">{filename}</h3>
|
||||
<div class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<button class="btn btn-sm btn-primary" onclick={saveContent}>Save</button>
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick={() => (isEditing = false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick={() => deleteFile(filename)}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#await getContent(filename)}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
{#if isEditing}
|
||||
<textarea
|
||||
class="w-full h-[300px] sm:h-[500px] font-mono p-2 bg-gray-800 text-white"
|
||||
bind:value={content}></textarea>
|
||||
{#await getContent(filename)}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
{#if isEditing}
|
||||
<textarea
|
||||
class="w-full h-[300px] sm:h-[500px] font-mono p-2 bg-gray-800 text-white"
|
||||
bind:value={content}
|
||||
></textarea>
|
||||
{:else}
|
||||
<pre
|
||||
class="bg-gray-800 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px]">{content}</pre>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<pre
|
||||
class="bg-gray-800 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px]">{content}</pre>
|
||||
<div class="text-center text-gray-500">Select a file to view its contents</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="text-center text-gray-500">Select a file to view its contents</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- </SettingsCard> -->
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Folder from './Folder.svelte'
|
||||
import File from './File.svelte'
|
||||
import { FolderIcon, FolderOpenOutline } from '$lib/components/icons'
|
||||
import Folder from './Folder.svelte'
|
||||
import File from './File.svelte'
|
||||
import { FolderIcon, FolderOpenOutline } from '$lib/components/icons'
|
||||
|
||||
interface Props {
|
||||
expanded?: boolean
|
||||
name: string
|
||||
files: any
|
||||
selected: (name: string) => void
|
||||
onDelete: (name: string) => void
|
||||
}
|
||||
interface Props {
|
||||
expanded?: boolean
|
||||
name: string
|
||||
files: any
|
||||
selected: (name: string) => void
|
||||
onDelete: (name: string) => void
|
||||
}
|
||||
|
||||
let { expanded = $bindable(false), name, files, selected, onDelete }: Props = $props()
|
||||
let { expanded = $bindable(false), name, files, selected, onDelete }: Props = $props()
|
||||
|
||||
function toggle() {
|
||||
expanded = !expanded
|
||||
}
|
||||
function toggle() {
|
||||
expanded = !expanded
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="folder-item">
|
||||
<button class="flex items-center pl-2 hover:bg-gray-700 w-full rounded py-1" onclick={toggle}>
|
||||
{#if expanded}
|
||||
<FolderOpenOutline class="w-5 h-5 mr-1" />
|
||||
{:else}
|
||||
<FolderIcon class="w-5 h-5 mr-1" />
|
||||
{/if}
|
||||
<span class="text-sm">{name}</span>
|
||||
</button>
|
||||
<button class="flex items-center pl-2 hover:bg-gray-700 w-full rounded py-1" onclick={toggle}>
|
||||
{#if expanded}
|
||||
<FolderOpenOutline class="w-5 h-5 mr-1" />
|
||||
{:else}
|
||||
<FolderIcon class="w-5 h-5 mr-1" />
|
||||
{/if}
|
||||
<span class="text-sm">{name}</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<ul class="ml-4 border-l border-gray-600 mt-1">
|
||||
{#each Object.entries(files) as [itemName, content]}
|
||||
<li class="py-1">
|
||||
{#if typeof content === 'object'}
|
||||
<Folder name={itemName} files={content} {selected} {onDelete} />
|
||||
{:else}
|
||||
<File name={itemName} {selected} {onDelete} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if expanded}
|
||||
<ul class="ml-4 border-l border-gray-600 mt-1">
|
||||
{#each Object.entries(files) as [itemName, content]}
|
||||
<li class="py-1">
|
||||
{#if typeof content === 'object'}
|
||||
<Folder name={itemName} files={content} {selected} {onDelete} />
|
||||
{:else}
|
||||
<File name={itemName} {selected} {onDelete} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { focusTrap } from 'svelte-focus-trap'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { exitBeforeEnter, modals, type ModalProps } from 'svelte-modals'
|
||||
import { Cancel, Check } from '$lib/components/icons'
|
||||
import { focusTrap } from 'svelte-focus-trap'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { exitBeforeEnter, modals, type ModalProps } from 'svelte-modals'
|
||||
import { Cancel, Check } from '$lib/components/icons'
|
||||
|
||||
let { isOpen, onConfirm }: ModalProps = $props()
|
||||
let fileName = $state('')
|
||||
let { isOpen, onConfirm }: ModalProps = $props()
|
||||
let fileName = $state('')
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!fileName) return
|
||||
onConfirm(fileName)
|
||||
modals.close()
|
||||
}
|
||||
const handleCreate = () => {
|
||||
if (!fileName) return
|
||||
onConfirm(fileName)
|
||||
modals.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
role="dialog"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||
transition:fly={{ y: 50 }}
|
||||
use:exitBeforeEnter
|
||||
use:focusTrap>
|
||||
<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">
|
||||
<h2 class="text-base-content text-start text-2xl font-bold">Create New File</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="File name"
|
||||
bind:value={fileName} />
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}>
|
||||
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
|
||||
</button>
|
||||
<button class="btn btn-primary inline-flex items-center" onclick={handleCreate}>
|
||||
<Check class="mr-2 h-5 w-5" /><span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
role="dialog"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||
transition:fly={{ y: 50 }}
|
||||
use:exitBeforeEnter
|
||||
use:focusTrap
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<h2 class="text-base-content text-start text-2xl font-bold">Create New File</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="File name"
|
||||
bind:value={fileName}
|
||||
/>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-error inline-flex items-center"
|
||||
onclick={() => modals.close()}
|
||||
>
|
||||
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
|
||||
</button>
|
||||
<button class="btn btn-primary inline-flex items-center" onclick={handleCreate}>
|
||||
<Check class="mr-2 h-5 w-5" /><span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { focusTrap } from 'svelte-focus-trap'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { exitBeforeEnter, modals, type ModalProps } from 'svelte-modals'
|
||||
import { Cancel, Check } from '$lib/components/icons'
|
||||
import { focusTrap } from 'svelte-focus-trap'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { exitBeforeEnter, modals, type ModalProps } from 'svelte-modals'
|
||||
import { Cancel, Check } from '$lib/components/icons'
|
||||
|
||||
let { isOpen, onConfirm }: ModalProps = $props()
|
||||
let folderName = $state('')
|
||||
let { isOpen, onConfirm }: ModalProps = $props()
|
||||
let folderName = $state('')
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!folderName) return
|
||||
onConfirm(folderName)
|
||||
modals.close()
|
||||
}
|
||||
const handleCreate = () => {
|
||||
if (!folderName) return
|
||||
onConfirm(folderName)
|
||||
modals.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
role="dialog"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||
transition:fly={{ y: 50 }}
|
||||
use:exitBeforeEnter
|
||||
use:focusTrap>
|
||||
<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">
|
||||
<h2 class="text-base-content text-start text-2xl font-bold">Create New Folder</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Folder name"
|
||||
bind:value={folderName} />
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}>
|
||||
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
|
||||
</button>
|
||||
<button class="btn btn-primary inline-flex items-center" onclick={handleCreate}>
|
||||
<Check class="mr-2 h-5 w-5" /><span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
role="dialog"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||
transition:fly={{ y: 50 }}
|
||||
use:exitBeforeEnter
|
||||
use:focusTrap
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<h2 class="text-base-content text-start text-2xl font-bold">Create New Folder</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Folder name"
|
||||
bind:value={folderName}
|
||||
/>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-error inline-flex items-center"
|
||||
onclick={() => modals.close()}
|
||||
>
|
||||
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
|
||||
</button>
|
||||
<button class="btn btn-primary inline-flex items-center" onclick={handleCreate}>
|
||||
<Check class="mr-2 h-5 w-5" /><span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SystemMetrics from './SystemMetrics.svelte';
|
||||
import SystemMetrics from './SystemMetrics.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<SystemMetrics />
|
||||
<SystemMetrics />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return { title: 'System Metrics' };
|
||||
}) satisfies PageLoad;
|
||||
return { title: 'System Metrics' }
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,271 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
import { daisyColor } from '$lib/utilities'
|
||||
import { analytics } from '$lib/stores/analytics'
|
||||
import { Metrics } from '$lib/components/icons'
|
||||
import { daisyColor } from '$lib/utilities'
|
||||
import { analytics } from '$lib/stores/analytics'
|
||||
import { Metrics } from '$lib/components/icons'
|
||||
|
||||
Chart.register(...registerables)
|
||||
Chart.register(...registerables)
|
||||
|
||||
let heapChartElement: HTMLCanvasElement
|
||||
let heapChart: Chart
|
||||
let heapChartElement: HTMLCanvasElement
|
||||
let heapChart: Chart
|
||||
|
||||
let filesystemChartElement: HTMLCanvasElement
|
||||
let filesystemChart: Chart
|
||||
let filesystemChartElement: HTMLCanvasElement
|
||||
let filesystemChart: Chart
|
||||
|
||||
let temperatureChartElement: HTMLCanvasElement
|
||||
let temperatureChart: Chart
|
||||
let temperatureChartElement: HTMLCanvasElement
|
||||
let temperatureChart: Chart
|
||||
|
||||
onMount(() => {
|
||||
heapChart = new Chart(heapChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: $analytics.uptime,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Used Heap',
|
||||
borderColor: daisyColor('--color-primary'),
|
||||
backgroundColor: daisyColor('--color-primary', 50),
|
||||
borderWidth: 2,
|
||||
data: $analytics.used_heap,
|
||||
fill: true,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: daisyColor('--color-base-content', 10)
|
||||
onMount(() => {
|
||||
heapChart = new Chart(heapChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: $analytics.uptime,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Used Heap',
|
||||
borderColor: daisyColor('--color-primary'),
|
||||
backgroundColor: daisyColor('--color-primary', 50),
|
||||
borderWidth: 2,
|
||||
data: $analytics.used_heap,
|
||||
fill: true,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: daisyColor('--color-base-content', 10)
|
||||
},
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Heap [kb]',
|
||||
color: daisyColor('--color-base-content'),
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: Math.round($analytics.total_heap[0]),
|
||||
grid: { color: daisyColor('--color-base-content', 10) },
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
border: { color: daisyColor('--color-base-content', 10) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
filesystemChart = new Chart(filesystemChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: $analytics.uptime,
|
||||
datasets: [
|
||||
{
|
||||
label: 'File System Used',
|
||||
borderColor: daisyColor('--color-primary'),
|
||||
backgroundColor: daisyColor('--color-primary', 50),
|
||||
borderWidth: 2,
|
||||
data: $analytics.fs_used,
|
||||
fill: true,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Heap [kb]',
|
||||
color: daisyColor('--color-base-content'),
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: daisyColor('--color-base-content', 10)
|
||||
},
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'File System [kb]',
|
||||
color: daisyColor('--color-base-content'),
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: Math.round($analytics.fs_total[0]),
|
||||
grid: { color: daisyColor('--color-base-content', 10) },
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
border: { color: daisyColor('--color-base-content', 10) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
temperatureChart = new Chart(temperatureChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: $analytics.uptime,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Core Temperature',
|
||||
borderColor: daisyColor('--color-primary'),
|
||||
backgroundColor: daisyColor('--color-primary', 50),
|
||||
borderWidth: 2,
|
||||
data: $analytics.core_temp,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: Math.round($analytics.total_heap[0]),
|
||||
grid: { color: daisyColor('--color-base-content', 10) },
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
border: { color: daisyColor('--color-base-content', 10) }
|
||||
}
|
||||
}
|
||||
}
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: daisyColor('--color-base-content', 10)
|
||||
},
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Core Temperature [°C]',
|
||||
color: daisyColor('--color-base-content'),
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
position: 'left',
|
||||
suggestedMin: 20,
|
||||
suggestedMax: 100,
|
||||
grid: { color: daisyColor('--color-base-content', 10) },
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
border: { color: daisyColor('--color-base-content', 10) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
setInterval(updateData, 500)
|
||||
})
|
||||
filesystemChart = new Chart(filesystemChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: $analytics.uptime,
|
||||
datasets: [
|
||||
{
|
||||
label: 'File System Used',
|
||||
borderColor: daisyColor('--color-primary'),
|
||||
backgroundColor: daisyColor('--color-primary', 50),
|
||||
borderWidth: 2,
|
||||
data: $analytics.fs_used,
|
||||
fill: true,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: daisyColor('--color-base-content', 10)
|
||||
},
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'File System [kb]',
|
||||
color: daisyColor('--color-base-content'),
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: Math.round($analytics.fs_total[0]),
|
||||
grid: { color: daisyColor('--color-base-content', 10) },
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
border: { color: daisyColor('--color-base-content', 10) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
temperatureChart = new Chart(temperatureChartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: $analytics.uptime,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Core Temperature',
|
||||
borderColor: daisyColor('--color-primary'),
|
||||
backgroundColor: daisyColor('--color-primary', 50),
|
||||
borderWidth: 2,
|
||||
data: $analytics.core_temp,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: daisyColor('--color-base-content', 10)
|
||||
},
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Core Temperature [°C]',
|
||||
color: daisyColor('--color-base-content'),
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
position: 'left',
|
||||
suggestedMin: 20,
|
||||
suggestedMax: 100,
|
||||
grid: { color: daisyColor('--color-base-content', 10) },
|
||||
ticks: {
|
||||
color: daisyColor('--color-base-content')
|
||||
},
|
||||
border: { color: daisyColor('--color-base-content', 10) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
setInterval(updateData, 500)
|
||||
})
|
||||
|
||||
function updateData() {
|
||||
heapChart.data.labels = $analytics.uptime
|
||||
heapChart.data.datasets[0].data = $analytics.used_heap
|
||||
heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0])
|
||||
heapChart.update('none')
|
||||
function updateData() {
|
||||
heapChart.data.labels = $analytics.uptime
|
||||
heapChart.data.datasets[0].data = $analytics.used_heap
|
||||
heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0])
|
||||
heapChart.update('none')
|
||||
|
||||
filesystemChart.data.labels = $analytics.uptime
|
||||
filesystemChart.data.datasets[0].data = $analytics.fs_used
|
||||
heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0])
|
||||
filesystemChart.update('none')
|
||||
filesystemChart.data.labels = $analytics.uptime
|
||||
filesystemChart.data.datasets[0].data = $analytics.fs_used
|
||||
heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0])
|
||||
filesystemChart.update('none')
|
||||
|
||||
temperatureChart.data.labels = $analytics.uptime
|
||||
temperatureChart.data.datasets[0].data = $analytics.core_temp
|
||||
temperatureChart.update('none')
|
||||
}
|
||||
temperatureChart.data.labels = $analytics.uptime
|
||||
temperatureChart.data.datasets[0].data = $analytics.core_temp
|
||||
temperatureChart.update('none')
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>System Metrics</span>
|
||||
{/snippet}
|
||||
{#snippet icon()}
|
||||
<Metrics class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>System Metrics</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<canvas bind:this={heapChartElement}></canvas>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-60"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<canvas bind:this={heapChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-52"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<canvas bind:this={filesystemChartElement}></canvas>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-52"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<canvas bind:this={filesystemChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-52"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<canvas bind:this={temperatureChartElement}></canvas>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1 h-52"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<canvas bind:this={temperatureChartElement}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SystemStatus from './SystemStatus.svelte'
|
||||
import SystemStatus from './SystemStatus.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<SystemStatus />
|
||||
<SystemStatus />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return { title: 'System Status' }
|
||||
return { title: 'System Status' }
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
const { icon, type, label, ...rest } = $props()
|
||||
const { icon, type, label, ...rest } = $props()
|
||||
|
||||
const Icon = $derived(icon)
|
||||
const Icon = $derived(icon)
|
||||
</script>
|
||||
|
||||
<button class="btn btn-{type} inline-flex items-center" {...rest}>
|
||||
<Icon class="mr-2 h-5 w-5" />
|
||||
<span>{label}</span>
|
||||
<Icon class="mr-2 h-5 w-5" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,252 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { modals } from 'svelte-modals'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { type SystemInformation, type Analytics, MessageTopic } from '$lib/types/models'
|
||||
import { socket } from '$lib/stores/socket'
|
||||
import { api } from '$lib/api'
|
||||
import { convertSeconds } from '$lib/utilities'
|
||||
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||
import {
|
||||
Cancel,
|
||||
Power,
|
||||
FactoryReset,
|
||||
Sleep,
|
||||
Health,
|
||||
CPU,
|
||||
SDK,
|
||||
CPP,
|
||||
Speed,
|
||||
Heap,
|
||||
Pyramid,
|
||||
Sketch,
|
||||
Flash,
|
||||
Folder,
|
||||
Temperature,
|
||||
Stopwatch
|
||||
} from '$lib/components/icons'
|
||||
import StatusItem from '$lib/components/StatusItem.svelte'
|
||||
import ActionButton from './ActionButton.svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { modals } from 'svelte-modals'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { type SystemInformation, type Analytics, MessageTopic } from '$lib/types/models'
|
||||
import { socket } from '$lib/stores/socket'
|
||||
import { api } from '$lib/api'
|
||||
import { convertSeconds } from '$lib/utilities'
|
||||
import { useFeatureFlags } from '$lib/stores/featureFlags'
|
||||
import {
|
||||
Cancel,
|
||||
Power,
|
||||
FactoryReset,
|
||||
Sleep,
|
||||
Health,
|
||||
CPU,
|
||||
SDK,
|
||||
CPP,
|
||||
Speed,
|
||||
Heap,
|
||||
Pyramid,
|
||||
Sketch,
|
||||
Flash,
|
||||
Folder,
|
||||
Temperature,
|
||||
Stopwatch
|
||||
} from '$lib/components/icons'
|
||||
import StatusItem from '$lib/components/StatusItem.svelte'
|
||||
import ActionButton from './ActionButton.svelte'
|
||||
|
||||
const features = useFeatureFlags()
|
||||
const features = useFeatureFlags()
|
||||
|
||||
let systemInformation: SystemInformation | null = $state(null)
|
||||
let systemInformation: SystemInformation | null = $state(null)
|
||||
|
||||
async function getSystemStatus() {
|
||||
const result = await api.get<SystemInformation>('/api/system/status')
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
async function getSystemStatus() {
|
||||
const result = await api.get<SystemInformation>('/api/system/status')
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
systemInformation = result.inner
|
||||
return systemInformation
|
||||
}
|
||||
systemInformation = result.inner
|
||||
return systemInformation
|
||||
}
|
||||
|
||||
const postFactoryReset = async () => await api.post('/api/system/reset')
|
||||
const postFactoryReset = async () => await api.post('/api/system/reset')
|
||||
|
||||
const postSleep = async () => await api.post('api/sleep')
|
||||
const postSleep = async () => await api.post('api/sleep')
|
||||
|
||||
onMount(() => socket.on(MessageTopic.analytics, handleSystemData))
|
||||
onMount(() => socket.on(MessageTopic.analytics, handleSystemData))
|
||||
|
||||
onDestroy(() => socket.off(MessageTopic.analytics, handleSystemData))
|
||||
const handleSystemData = (data: Analytics) => {
|
||||
if (systemInformation) {
|
||||
systemInformation = {
|
||||
...systemInformation,
|
||||
...(data as unknown as SystemInformation)
|
||||
}
|
||||
onDestroy(() => socket.off(MessageTopic.analytics, handleSystemData))
|
||||
const handleSystemData = (data: Analytics) => {
|
||||
if (systemInformation) {
|
||||
systemInformation = {
|
||||
...systemInformation,
|
||||
...(data as unknown as SystemInformation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postRestart = async () => await api.post('/api/system/restart')
|
||||
const postRestart = async () => await api.post('/api/system/restart')
|
||||
|
||||
function confirmRestart() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Restart',
|
||||
message: 'Are you sure you want to restart the device?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Restart', icon: Power }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
postRestart()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmReset() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Factory Reset',
|
||||
message: 'Are you sure you want to reset the device to its factory defaults?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Factory Reset', icon: FactoryReset }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
postFactoryReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmSleep() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Going to Sleep',
|
||||
message: 'Are you sure you want to put the device into sleep?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Sleep', icon: Sleep }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
postSleep()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ActionButtonDef {
|
||||
icon: any
|
||||
label: string
|
||||
onClick: () => void
|
||||
type?: string
|
||||
condition?: () => boolean
|
||||
}
|
||||
|
||||
const actionButtons: ActionButtonDef[] = [
|
||||
{
|
||||
icon: Sleep,
|
||||
label: 'Sleep',
|
||||
onClick: confirmSleep,
|
||||
condition: () => Boolean($features.sleep)
|
||||
},
|
||||
{
|
||||
icon: Power,
|
||||
label: 'Restart',
|
||||
onClick: confirmRestart
|
||||
},
|
||||
{
|
||||
icon: FactoryReset,
|
||||
label: 'Factory Reset',
|
||||
onClick: confirmReset,
|
||||
type: 'secondary'
|
||||
function confirmRestart() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Restart',
|
||||
message: 'Are you sure you want to restart the device?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Restart', icon: Power }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
postRestart()
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
function confirmReset() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Factory Reset',
|
||||
message: 'Are you sure you want to reset the device to its factory defaults?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Factory Reset', icon: FactoryReset }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
postFactoryReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmSleep() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Going to Sleep',
|
||||
message: 'Are you sure you want to put the device into sleep?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Sleep', icon: Sleep }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
postSleep()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ActionButtonDef {
|
||||
icon: any
|
||||
label: string
|
||||
onClick: () => void
|
||||
type?: string
|
||||
condition?: () => boolean
|
||||
}
|
||||
|
||||
const actionButtons: ActionButtonDef[] = [
|
||||
{
|
||||
icon: Sleep,
|
||||
label: 'Sleep',
|
||||
onClick: confirmSleep,
|
||||
condition: () => Boolean($features.sleep)
|
||||
},
|
||||
{
|
||||
icon: Power,
|
||||
label: 'Restart',
|
||||
onClick: confirmRestart
|
||||
},
|
||||
{
|
||||
icon: FactoryReset,
|
||||
label: 'Factory Reset',
|
||||
onClick: confirmReset,
|
||||
type: 'secondary'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<Health class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>System Status</span>
|
||||
{/snippet}
|
||||
{#snippet icon()}
|
||||
<Health class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>System Status</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="w-full overflow-x-auto">
|
||||
{#await getSystemStatus()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if systemInformation}
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<StatusItem
|
||||
icon={CPU}
|
||||
title="Chip"
|
||||
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`} />
|
||||
<div class="w-full overflow-x-auto">
|
||||
{#await getSystemStatus()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if systemInformation}
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<StatusItem
|
||||
icon={CPU}
|
||||
title="Chip"
|
||||
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={SDK}
|
||||
title="SDK Version"
|
||||
description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`} />
|
||||
<StatusItem
|
||||
icon={SDK}
|
||||
title="SDK Version"
|
||||
description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={CPP}
|
||||
title="Firmware Version"
|
||||
description={systemInformation.firmware_version} />
|
||||
<StatusItem
|
||||
icon={CPP}
|
||||
title="Firmware Version"
|
||||
description={systemInformation.firmware_version}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Speed}
|
||||
title="CPU Frequency"
|
||||
description={`${systemInformation.cpu_freq_mhz} MHz ${
|
||||
systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core'
|
||||
}`} />
|
||||
<StatusItem
|
||||
icon={Speed}
|
||||
title="CPU Frequency"
|
||||
description={`${systemInformation.cpu_freq_mhz} MHz ${
|
||||
systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Heap}
|
||||
title="Heap (Free / Max Alloc)"
|
||||
description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`} />
|
||||
<StatusItem
|
||||
icon={Heap}
|
||||
title="Heap (Free / Max Alloc)"
|
||||
description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Pyramid}
|
||||
title="PSRAM (Size / Free)"
|
||||
description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`} />
|
||||
<StatusItem
|
||||
icon={Pyramid}
|
||||
title="PSRAM (Size / Free)"
|
||||
description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Sketch}
|
||||
title="Sketch (Used / Free)"
|
||||
description={`${(
|
||||
(systemInformation.sketch_size / systemInformation.free_sketch_space) *
|
||||
100
|
||||
).toFixed(1)} % of
|
||||
<StatusItem
|
||||
icon={Sketch}
|
||||
title="Sketch (Used / Free)"
|
||||
description={`${(
|
||||
(systemInformation.sketch_size / systemInformation.free_sketch_space) *
|
||||
100
|
||||
).toFixed(1)} % of
|
||||
${systemInformation.free_sketch_space / 1000000} MB used (${
|
||||
(systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000
|
||||
} MB free)`} />
|
||||
(systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000
|
||||
} MB free)`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Flash}
|
||||
title="Flash Chip (Size / Speed)"
|
||||
description={`${systemInformation.flash_chip_size / 1000000} MB / ${
|
||||
systemInformation.flash_chip_speed / 1000000
|
||||
} MHz`} />
|
||||
<StatusItem
|
||||
icon={Flash}
|
||||
title="Flash Chip (Size / Speed)"
|
||||
description={`${systemInformation.flash_chip_size / 1000000} MB / ${
|
||||
systemInformation.flash_chip_speed / 1000000
|
||||
} MHz`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Folder}
|
||||
title="File System (Used / Total)"
|
||||
description={`${(
|
||||
(systemInformation.fs_used / systemInformation.fs_total) *
|
||||
100
|
||||
).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
|
||||
(systemInformation.fs_total - systemInformation.fs_used) / 1000000
|
||||
}
|
||||
MB free)`} />
|
||||
<StatusItem
|
||||
icon={Folder}
|
||||
title="File System (Used / Total)"
|
||||
description={`${(
|
||||
(systemInformation.fs_used / systemInformation.fs_total) *
|
||||
100
|
||||
).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${
|
||||
(systemInformation.fs_total - systemInformation.fs_used) / 1000000
|
||||
}
|
||||
MB free)`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Temperature}
|
||||
title="Core Temperature"
|
||||
description={`${
|
||||
systemInformation.core_temp == 53.33 ?
|
||||
'NaN'
|
||||
: systemInformation.core_temp.toFixed(2) + ' °C'
|
||||
}`} />
|
||||
<StatusItem
|
||||
icon={Temperature}
|
||||
title="Core Temperature"
|
||||
description={`${
|
||||
systemInformation.core_temp == 53.33 ?
|
||||
'NaN'
|
||||
: systemInformation.core_temp.toFixed(2) + ' °C'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Stopwatch}
|
||||
title="Uptime"
|
||||
description={convertSeconds(systemInformation.uptime)} />
|
||||
<StatusItem
|
||||
icon={Stopwatch}
|
||||
title="Uptime"
|
||||
description={convertSeconds(systemInformation.uptime)}
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
icon={Power}
|
||||
title="Reset Reason"
|
||||
description={systemInformation.cpu_reset_reason} />
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
<StatusItem
|
||||
icon={Power}
|
||||
title="Reset Reason"
|
||||
description={systemInformation.cpu_reset_reason}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2">
|
||||
{#each actionButtons as button}
|
||||
{#if button.condition === undefined || button.condition()}
|
||||
<ActionButton
|
||||
onclick={button.onClick}
|
||||
icon={button.icon}
|
||||
label={button.label}
|
||||
type={button.type || 'primary'} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2">
|
||||
{#each actionButtons as button}
|
||||
{#if button.condition === undefined || button.condition()}
|
||||
<ActionButton
|
||||
onclick={button.onClick}
|
||||
icon={button.icon}
|
||||
label={button.label}
|
||||
type={button.type || 'primary'}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import UploadFirmware from './UploadFirmware.svelte';
|
||||
import GithubFirmwareManager from './GithubFirmwareManager.svelte';
|
||||
import { useFeatureFlags } from '$lib/stores';
|
||||
import UploadFirmware from './UploadFirmware.svelte'
|
||||
import GithubFirmwareManager from './GithubFirmwareManager.svelte'
|
||||
import { useFeatureFlags } from '$lib/stores'
|
||||
|
||||
const features = useFeatureFlags();
|
||||
const features = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return { title: 'Firmware Update' };
|
||||
}) satisfies PageLoad;
|
||||
return { title: 'Firmware Update' }
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,154 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { modals } from 'svelte-modals';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import { page } from '$app/state'
|
||||
import { modals } from 'svelte-modals'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
|
||||
import InfoDialog from '$lib/components/InfoDialog.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { useFeatureFlags } from '$lib/stores';
|
||||
import { Error, Cancel, Check, CloudDown, Github, Prerelease } from '$lib/components/icons';
|
||||
import { compareVersions } from 'compare-versions'
|
||||
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
|
||||
import InfoDialog from '$lib/components/InfoDialog.svelte'
|
||||
import { api } from '$lib/api'
|
||||
import { useFeatureFlags } from '$lib/stores'
|
||||
import { Error, Cancel, Check, CloudDown, Github, Prerelease } from '$lib/components/icons'
|
||||
|
||||
const features = useFeatureFlags();
|
||||
const features = useFeatureFlags()
|
||||
|
||||
async function getGithubAPI() {
|
||||
const headers = {
|
||||
accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
};
|
||||
const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, {
|
||||
headers,
|
||||
});
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner);
|
||||
return;
|
||||
}
|
||||
return result.inner as any;
|
||||
}
|
||||
|
||||
async function postGithubDownload(url: string) {
|
||||
const result = await api.post('/api/firmware/download', { download_url: url });
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmGithubUpdate(assets: any) {
|
||||
let url = '';
|
||||
// iterate over assets and find the correct one
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
// check if the asset is of type *.bin
|
||||
if (
|
||||
assets[i].name.includes('.bin') &&
|
||||
assets[i].name.includes($features.firmware_built_target)
|
||||
) {
|
||||
url = assets[i].browser_download_url;
|
||||
}
|
||||
}
|
||||
if (url === '') {
|
||||
// if no asset was found, use the first one
|
||||
modals.open(InfoDialog, {
|
||||
title: 'No matching firmware found',
|
||||
message:
|
||||
'No matching firmware was found for the current device. Upload the firmware manually or build from sources.',
|
||||
dismiss: { label: 'OK', icon: Check },
|
||||
onDismiss: () => modals.close(),
|
||||
});
|
||||
return;
|
||||
async function getGithubAPI() {
|
||||
const headers = {
|
||||
accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
const result = await api.get(`https://api.github.com/repos/${page.data.github}/releases`, {
|
||||
headers
|
||||
})
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
return result.inner as any
|
||||
}
|
||||
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm flashing new firmware to the device',
|
||||
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Update', icon: CloudDown },
|
||||
},
|
||||
onConfirm: () => {
|
||||
postGithubDownload(url);
|
||||
modals.open(GithubUpdateDialog, {
|
||||
onConfirm: () => modals.closeAll(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
async function postGithubDownload(url: string) {
|
||||
const result = await api.post('/api/firmware/download', { download_url: url })
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function confirmGithubUpdate(assets: any) {
|
||||
let url = ''
|
||||
// iterate over assets and find the correct one
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
// check if the asset is of type *.bin
|
||||
if (
|
||||
assets[i].name.includes('.bin') &&
|
||||
assets[i].name.includes($features.firmware_built_target)
|
||||
) {
|
||||
url = assets[i].browser_download_url
|
||||
}
|
||||
}
|
||||
if (url === '') {
|
||||
// if no asset was found, use the first one
|
||||
modals.open(InfoDialog, {
|
||||
title: 'No matching firmware found',
|
||||
message:
|
||||
'No matching firmware was found for the current device. Upload the firmware manually or build from sources.',
|
||||
dismiss: { label: 'OK', icon: Check },
|
||||
onDismiss: () => modals.close()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm flashing new firmware to the device',
|
||||
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Update', icon: CloudDown }
|
||||
},
|
||||
onConfirm: () => {
|
||||
postGithubDownload(url)
|
||||
modals.open(GithubUpdateDialog, {
|
||||
onConfirm: () => modals.closeAll()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<Github class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Github Firmware Manager</span>
|
||||
{/snippet}
|
||||
{#await getGithubAPI()}
|
||||
<Spinner />
|
||||
{:then githubReleases}
|
||||
<div class="relative w-full overflow-visible">
|
||||
<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">Release</th>
|
||||
<th align="center" class="hidden sm:block">Release Date</th>
|
||||
<th align="center">Experimental</th>
|
||||
<th align="center">Install</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each githubReleases as release}
|
||||
<tr
|
||||
class={(
|
||||
compareVersions($features.firmware_version as string, release.tag_name) === 0
|
||||
) ?
|
||||
'bg-primary text-primary-content'
|
||||
: 'bg-base-100 h-14'}>
|
||||
<td align="left" class="text-base font-semibold">
|
||||
<a
|
||||
href={release.html_url}
|
||||
class="link link-hover"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{release.name}</a
|
||||
></td>
|
||||
<td align="center" class="hidden min-h-full align-middle sm:block">
|
||||
<div class="my-2">
|
||||
{new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
}).format(new Date(release.published_at))}
|
||||
</div>
|
||||
</td>
|
||||
<td align="center">
|
||||
{#if release.prerelease}
|
||||
<Prerelease class="text-accent h-5 w-5" />
|
||||
{/if}
|
||||
</td>
|
||||
<td align="center">
|
||||
{#if compareVersions($features.firmware_version as string, release.tag_name) != 0}
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-sm"
|
||||
onclick={() => {
|
||||
confirmGithubUpdate(release.assets);
|
||||
}}>
|
||||
<CloudDown class="text-secondary h-6 w-6" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:catch error}
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<Error class="h-6 w-6 shrink-0" />
|
||||
<span>Please connect to a network with internet access to perform a firmware update.</span>
|
||||
</div>
|
||||
{/await}
|
||||
{#snippet icon()}
|
||||
<Github class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Github Firmware Manager</span>
|
||||
{/snippet}
|
||||
{#await getGithubAPI()}
|
||||
<Spinner />
|
||||
{:then githubReleases}
|
||||
<div class="relative w-full overflow-visible">
|
||||
<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">Release</th>
|
||||
<th align="center" class="hidden sm:block">Release Date</th>
|
||||
<th align="center">Experimental</th>
|
||||
<th align="center">Install</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each githubReleases as release}
|
||||
<tr
|
||||
class={(
|
||||
compareVersions(
|
||||
$features.firmware_version as string,
|
||||
release.tag_name
|
||||
) === 0
|
||||
) ?
|
||||
'bg-primary text-primary-content'
|
||||
: 'bg-base-100 h-14'}
|
||||
>
|
||||
<td align="left" class="text-base font-semibold">
|
||||
<a
|
||||
href={release.html_url}
|
||||
class="link link-hover"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{release.name}</a
|
||||
></td
|
||||
>
|
||||
<td align="center" class="hidden min-h-full align-middle sm:block">
|
||||
<div class="my-2">
|
||||
{new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium'
|
||||
}).format(new Date(release.published_at))}
|
||||
</div>
|
||||
</td>
|
||||
<td align="center">
|
||||
{#if release.prerelease}
|
||||
<Prerelease class="text-accent h-5 w-5" />
|
||||
{/if}
|
||||
</td>
|
||||
<td align="center">
|
||||
{#if compareVersions($features.firmware_version as string, release.tag_name) != 0}
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-sm"
|
||||
onclick={() => {
|
||||
confirmGithubUpdate(release.assets)
|
||||
}}
|
||||
>
|
||||
<CloudDown class="text-secondary h-6 w-6" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:catch error}
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<Error class="h-6 w-6 shrink-0" />
|
||||
<span
|
||||
>Please connect to a network with internet access to perform a firmware update.</span
|
||||
>
|
||||
</div>
|
||||
{/await}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { modals } from 'svelte-modals';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import { modals } from 'svelte-modals'
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { Cancel, OTA, Warning } from '$lib/components/icons';
|
||||
import { api } from '$lib/api'
|
||||
import { Cancel, OTA, Warning } from '$lib/components/icons'
|
||||
|
||||
let files: FileList | undefined = $state();
|
||||
let files: FileList | undefined = $state()
|
||||
|
||||
async function uploadBIN() {
|
||||
const formData = new FormData();
|
||||
formData.append('file', files![0]);
|
||||
const result = await api.post('/api/firmware', formData);
|
||||
if (result.isErr()) console.error('Error:', result.inner);
|
||||
}
|
||||
async function uploadBIN() {
|
||||
const formData = new FormData()
|
||||
formData.append('file', files![0])
|
||||
const result = await api.post('/api/firmware', formData)
|
||||
if (result.isErr()) console.error('Error:', result.inner)
|
||||
}
|
||||
|
||||
function confirmBinUpload() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Flashing the Device',
|
||||
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Upload', icon: OTA },
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close();
|
||||
uploadBIN();
|
||||
},
|
||||
});
|
||||
}
|
||||
function confirmBinUpload() {
|
||||
modals.open(ConfirmDialog, {
|
||||
title: 'Confirm Flashing the Device',
|
||||
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
|
||||
labels: {
|
||||
cancel: { label: 'Abort', icon: Cancel },
|
||||
confirm: { label: 'Upload', icon: OTA }
|
||||
},
|
||||
onConfirm: () => {
|
||||
modals.close()
|
||||
uploadBIN()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<OTA class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Upload Firmware</span>
|
||||
{/snippet}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<Warning class="h-6 w-6 shrink-0" />
|
||||
<span
|
||||
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload a
|
||||
(.md5) file first to verify the uploaded firmware.
|
||||
</span>
|
||||
</div>
|
||||
{#snippet icon()}
|
||||
<OTA class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Upload Firmware</span>
|
||||
{/snippet}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<Warning class="h-6 w-6 shrink-0" />
|
||||
<span
|
||||
>Uploading a new firmware (.bin) file will replace the existing firmware. You may upload
|
||||
a (.md5) file first to verify the uploaded firmware.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="binFile"
|
||||
class="file-input file-input-bordered file-input-secondary mt-4 w-full"
|
||||
bind:files
|
||||
accept=".bin,.md5"
|
||||
onchange={confirmBinUpload} />
|
||||
<input
|
||||
type="file"
|
||||
id="binFile"
|
||||
class="file-input file-input-bordered file-input-secondary mt-4 w-full"
|
||||
bind:files
|
||||
accept=".bin,.md5"
|
||||
onchange={confirmBinUpload}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageLoad } from './$types'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
export const load = (async () => {
|
||||
goto('/');
|
||||
return;
|
||||
}) satisfies PageLoad;
|
||||
goto('/')
|
||||
return
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Accesspoint from './Accesspoint.svelte';
|
||||
import Accesspoint from './Accesspoint.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<Accesspoint />
|
||||
<Accesspoint />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'Access Point'
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
title: 'Access Point'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
@@ -1,365 +1,401 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
import { preventDefault } from 'svelte/legacy'
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { PasswordInput } from '$lib/components/input';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import { notifications } from '$lib/components/toasts/notifications';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import type { ApSettings, ApStatus } from '$lib/types/models';
|
||||
import { api } from '$lib/api';
|
||||
import { AP, Devices, Home, MAC } from '$lib/components/icons';
|
||||
import StatusItem from '$lib/components/StatusItem.svelte';
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { PasswordInput } from '$lib/components/input'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { notifications } from '$lib/components/toasts/notifications'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import type { ApSettings, ApStatus } from '$lib/types/models'
|
||||
import { api } from '$lib/api'
|
||||
import { AP, Devices, Home, MAC } from '$lib/components/icons'
|
||||
import StatusItem from '$lib/components/StatusItem.svelte'
|
||||
|
||||
let apSettings: ApSettings | null = $state(null);
|
||||
let apStatus: ApStatus | null = $state(null);
|
||||
let apSettings: ApSettings | null = $state(null)
|
||||
let apStatus: ApStatus | null = $state(null)
|
||||
|
||||
let formField: any = $state();
|
||||
let formField: any = $state()
|
||||
|
||||
async function getAPStatus() {
|
||||
const result = await api.get<ApStatus>('/api/wifi/ap/status');
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner);
|
||||
return;
|
||||
}
|
||||
apStatus = result.inner;
|
||||
return apStatus;
|
||||
}
|
||||
|
||||
async function getAPSettings() {
|
||||
const result = await api.get<ApSettings>('/api/wifi/ap/settings');
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner);
|
||||
return;
|
||||
}
|
||||
apSettings = result.inner;
|
||||
return apSettings;
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
getAPStatus();
|
||||
}, 5000);
|
||||
|
||||
onDestroy(() => clearInterval(interval));
|
||||
|
||||
onMount(getAPSettings);
|
||||
|
||||
let provisionMode = [
|
||||
{
|
||||
id: 0,
|
||||
text: `Always`,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
text: `When WiFi Disconnected`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: `Never`,
|
||||
},
|
||||
];
|
||||
|
||||
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning';
|
||||
|
||||
let apStatusVariant: Variant[] = ['success', 'error', 'warning'];
|
||||
|
||||
let apStatusDescription = ['Active', 'Inactive', 'Lingering'];
|
||||
|
||||
let formErrors = $state({
|
||||
ssid: false,
|
||||
channel: false,
|
||||
max_clients: false,
|
||||
local_ip: false,
|
||||
gateway_ip: false,
|
||||
subnet_mask: false,
|
||||
});
|
||||
|
||||
async function postAPSettings(data: ApSettings) {
|
||||
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data);
|
||||
if (result.isErr()) {
|
||||
notifications.error('User not authorized.', 3000);
|
||||
console.error('Error:', result.inner);
|
||||
return;
|
||||
}
|
||||
notifications.success('Access Point settings updated.', 3000);
|
||||
apSettings = result.inner;
|
||||
}
|
||||
|
||||
function handleSubmitAP() {
|
||||
if (!apSettings) return;
|
||||
let valid = true;
|
||||
|
||||
// Validate SSID
|
||||
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
|
||||
valid = false;
|
||||
formErrors.ssid = true;
|
||||
} else {
|
||||
formErrors.ssid = false;
|
||||
async function getAPStatus() {
|
||||
const result = await api.get<ApStatus>('/api/wifi/ap/status')
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
apStatus = result.inner
|
||||
return apStatus
|
||||
}
|
||||
|
||||
// Validate Channel
|
||||
let channel = Number(apSettings.channel);
|
||||
if (1 > channel || channel > 13) {
|
||||
valid = false;
|
||||
formErrors.channel = true;
|
||||
} else {
|
||||
formErrors.channel = false;
|
||||
async function getAPSettings() {
|
||||
const result = await api.get<ApSettings>('/api/wifi/ap/settings')
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
apSettings = result.inner
|
||||
return apSettings
|
||||
}
|
||||
|
||||
// Validate max_clients
|
||||
let maxClients = Number(apSettings.max_clients);
|
||||
if (1 > maxClients || maxClients > 8) {
|
||||
valid = false;
|
||||
formErrors.max_clients = true;
|
||||
} else {
|
||||
formErrors.max_clients = false;
|
||||
const interval = setInterval(async () => {
|
||||
getAPStatus()
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => clearInterval(interval))
|
||||
|
||||
onMount(getAPSettings)
|
||||
|
||||
let provisionMode = [
|
||||
{
|
||||
id: 0,
|
||||
text: `Always`
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
text: `When WiFi Disconnected`
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: `Never`
|
||||
}
|
||||
]
|
||||
|
||||
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
|
||||
|
||||
let apStatusVariant: Variant[] = ['success', 'error', 'warning']
|
||||
|
||||
let apStatusDescription = ['Active', 'Inactive', 'Lingering']
|
||||
|
||||
let formErrors = $state({
|
||||
ssid: false,
|
||||
channel: false,
|
||||
max_clients: false,
|
||||
local_ip: false,
|
||||
gateway_ip: false,
|
||||
subnet_mask: false
|
||||
})
|
||||
|
||||
async function postAPSettings(data: ApSettings) {
|
||||
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data)
|
||||
if (result.isErr()) {
|
||||
notifications.error('User not authorized.', 3000)
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
notifications.success('Access Point settings updated.', 3000)
|
||||
apSettings = result.inner
|
||||
}
|
||||
|
||||
// RegEx for IPv4
|
||||
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/;
|
||||
function handleSubmitAP() {
|
||||
if (!apSettings) return
|
||||
let valid = true
|
||||
|
||||
// Validate gateway IP
|
||||
if (!regexExp.test(apSettings.gateway_ip)) {
|
||||
valid = false;
|
||||
formErrors.gateway_ip = true;
|
||||
} else {
|
||||
formErrors.gateway_ip = false;
|
||||
}
|
||||
// Validate SSID
|
||||
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
|
||||
valid = false
|
||||
formErrors.ssid = true
|
||||
} else {
|
||||
formErrors.ssid = false
|
||||
}
|
||||
|
||||
// Validate Subnet Mask
|
||||
if (!regexExp.test(apSettings.subnet_mask)) {
|
||||
valid = false;
|
||||
formErrors.subnet_mask = true;
|
||||
} else {
|
||||
formErrors.subnet_mask = false;
|
||||
}
|
||||
// Validate Channel
|
||||
let channel = Number(apSettings.channel)
|
||||
if (1 > channel || channel > 13) {
|
||||
valid = false
|
||||
formErrors.channel = true
|
||||
} else {
|
||||
formErrors.channel = false
|
||||
}
|
||||
|
||||
// Validate local IP
|
||||
if (!regexExp.test(apSettings.local_ip)) {
|
||||
valid = false;
|
||||
formErrors.local_ip = true;
|
||||
} else {
|
||||
formErrors.local_ip = false;
|
||||
}
|
||||
// Validate max_clients
|
||||
let maxClients = Number(apSettings.max_clients)
|
||||
if (1 > maxClients || maxClients > 8) {
|
||||
valid = false
|
||||
formErrors.max_clients = true
|
||||
} else {
|
||||
formErrors.max_clients = false
|
||||
}
|
||||
|
||||
// Submit JSON to REST API
|
||||
if (valid) {
|
||||
postAPSettings(apSettings);
|
||||
// RegEx for IPv4
|
||||
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/
|
||||
|
||||
// Validate gateway IP
|
||||
if (!regexExp.test(apSettings.gateway_ip)) {
|
||||
valid = false
|
||||
formErrors.gateway_ip = true
|
||||
} else {
|
||||
formErrors.gateway_ip = false
|
||||
}
|
||||
|
||||
// Validate Subnet Mask
|
||||
if (!regexExp.test(apSettings.subnet_mask)) {
|
||||
valid = false
|
||||
formErrors.subnet_mask = true
|
||||
} else {
|
||||
formErrors.subnet_mask = false
|
||||
}
|
||||
|
||||
// Validate local IP
|
||||
if (!regexExp.test(apSettings.local_ip)) {
|
||||
valid = false
|
||||
formErrors.local_ip = true
|
||||
} else {
|
||||
formErrors.local_ip = false
|
||||
}
|
||||
|
||||
// Submit JSON to REST API
|
||||
if (valid) {
|
||||
postAPSettings(apSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Access Point</span>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-x-auto">
|
||||
{#await getAPStatus()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if apStatus}
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<StatusItem
|
||||
icon={AP}
|
||||
title="Status"
|
||||
variant={apStatusVariant[apStatus.status]}
|
||||
description={apStatusDescription[apStatus.status]} />
|
||||
{#snippet icon()}
|
||||
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>Access Point</span>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-x-auto">
|
||||
{#await getAPStatus()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if apStatus}
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<StatusItem
|
||||
icon={AP}
|
||||
title="Status"
|
||||
variant={apStatusVariant[apStatus.status]}
|
||||
description={apStatusDescription[apStatus.status]}
|
||||
/>
|
||||
|
||||
<StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} />
|
||||
<StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} />
|
||||
|
||||
<StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} />
|
||||
<StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} />
|
||||
|
||||
<StatusItem icon={Devices} title="AP Clients" description={apStatus.station_num} />
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
|
||||
<div
|
||||
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium">
|
||||
Change AP Settings
|
||||
<StatusItem
|
||||
icon={Devices}
|
||||
title="AP Clients"
|
||||
description={apStatus.station_num}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
{#await getAPSettings()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if apSettings}
|
||||
|
||||
<div class="bg-base-200 relative grid w-full max-w-2xl self-center overflow-hidden">
|
||||
<div
|
||||
class="flex flex-col gap-2 p-0"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<form
|
||||
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
|
||||
onsubmit={preventDefault(handleSubmitAP)}
|
||||
novalidate
|
||||
bind:this={formField}>
|
||||
<div>
|
||||
<label class="label" for="apmode">
|
||||
<span class="label-text">Provide Access Point ...</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
id="apmode"
|
||||
bind:value={apSettings.provision_mode}>
|
||||
{#each provisionMode as mode}
|
||||
<option value={mode.id}>
|
||||
{mode.text}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="ssid">
|
||||
<span class="label-text text-md">SSID</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
|
||||
formErrors.ssid
|
||||
) ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
bind:value={apSettings.ssid}
|
||||
id="ssid"
|
||||
min="2"
|
||||
max="32"
|
||||
required />
|
||||
<label class="label" for="ssid">
|
||||
<span class="label-text-alt text-error {formErrors.ssid ? '' : 'hidden'}"
|
||||
>SSID must be between 2 and 32 characters long</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="pwd">
|
||||
<span class="label-text text-md">Password</span>
|
||||
</label>
|
||||
<PasswordInput bind:value={apSettings.password} id="pwd" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="channel">
|
||||
<span class="label-text text-md">Preferred Channel</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="13"
|
||||
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
|
||||
formErrors.channel
|
||||
) ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
bind:value={apSettings.channel}
|
||||
id="channel"
|
||||
required />
|
||||
<label class="label" for="channel">
|
||||
<span class="label-text-alt text-error {formErrors.channel ? '' : 'hidden'}"
|
||||
>Must be channel 1 to 13</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="clients">
|
||||
<span class="label-text text-md">Max Clients</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="8"
|
||||
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
|
||||
formErrors.max_clients
|
||||
) ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
bind:value={apSettings.max_clients}
|
||||
id="clients"
|
||||
required />
|
||||
<label class="label" for="clients">
|
||||
<span class="label-text-alt text-error {formErrors.max_clients ? '' : 'hidden'}"
|
||||
>Maximum 8 clients allowed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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={apSettings.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={apSettings.gateway_ip}
|
||||
id="gateway"
|
||||
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 ? 'border-error border-2'
|
||||
: ''}"
|
||||
minlength="7"
|
||||
maxlength="15"
|
||||
size="15"
|
||||
bind:value={apSettings.subnet_mask}
|
||||
id="subnet"
|
||||
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>
|
||||
|
||||
<label class="label my-auto cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={apSettings.ssid_hidden}
|
||||
class="checkbox checkbox-primary" />
|
||||
<span class="">Hide SSID</span>
|
||||
</label>
|
||||
|
||||
<div class="place-self-end">
|
||||
<button class="btn btn-primary" type="submit">Apply Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
class="min-h-16 flex w-full items-center justify-between space-x-3 p-0 text-xl font-medium"
|
||||
>
|
||||
Change AP Settings
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
{#await getAPSettings()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if apSettings}
|
||||
<div
|
||||
class="flex flex-col gap-2 p-0"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<form
|
||||
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
|
||||
onsubmit={preventDefault(handleSubmitAP)}
|
||||
novalidate
|
||||
bind:this={formField}
|
||||
>
|
||||
<div>
|
||||
<label class="label" for="apmode">
|
||||
<span class="label-text">Provide Access Point ...</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
id="apmode"
|
||||
bind:value={apSettings.provision_mode}
|
||||
>
|
||||
{#each provisionMode as mode}
|
||||
<option value={mode.id}>
|
||||
{mode.text}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="ssid">
|
||||
<span class="label-text text-md">SSID</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
|
||||
formErrors.ssid
|
||||
) ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
bind:value={apSettings.ssid}
|
||||
id="ssid"
|
||||
min="2"
|
||||
max="32"
|
||||
required
|
||||
/>
|
||||
<label class="label" for="ssid">
|
||||
<span
|
||||
class="label-text-alt text-error {formErrors.ssid ? '' : (
|
||||
'hidden'
|
||||
)}">SSID must be between 2 and 32 characters long</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="pwd">
|
||||
<span class="label-text text-md">Password</span>
|
||||
</label>
|
||||
<PasswordInput bind:value={apSettings.password} id="pwd" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="channel">
|
||||
<span class="label-text text-md">Preferred Channel</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="13"
|
||||
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
|
||||
formErrors.channel
|
||||
) ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
bind:value={apSettings.channel}
|
||||
id="channel"
|
||||
required
|
||||
/>
|
||||
<label class="label" for="channel">
|
||||
<span
|
||||
class="label-text-alt text-error {formErrors.channel ? '' : (
|
||||
'hidden'
|
||||
)}">Must be channel 1 to 13</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="clients">
|
||||
<span class="label-text text-md">Max Clients</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="8"
|
||||
class="input input-bordered invalid:border-error w-full invalid:border-2 {(
|
||||
formErrors.max_clients
|
||||
) ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
bind:value={apSettings.max_clients}
|
||||
id="clients"
|
||||
required
|
||||
/>
|
||||
<label class="label" for="clients">
|
||||
<span
|
||||
class="label-text-alt text-error {formErrors.max_clients ? ''
|
||||
: 'hidden'}">Maximum 8 clients allowed</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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={apSettings.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={apSettings.gateway_ip}
|
||||
id="gateway"
|
||||
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 ?
|
||||
'border-error border-2'
|
||||
: ''}"
|
||||
minlength="7"
|
||||
maxlength="15"
|
||||
size="15"
|
||||
bind:value={apSettings.subnet_mask}
|
||||
id="subnet"
|
||||
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>
|
||||
|
||||
<label class="label my-auto cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={apSettings.ssid_hidden}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<span class="">Hide SSID</span>
|
||||
</label>
|
||||
|
||||
<div class="place-self-end">
|
||||
<button class="btn btn-primary" type="submit">Apply Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MDNS from './MDNS.svelte'
|
||||
import MDNS from './MDNS.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<MDNS />
|
||||
<MDNS />
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '$lib/api'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { AP, Home, MAC, Devices } from '$lib/components/icons'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import StatusItem from '$lib/components/StatusItem.svelte'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { slide } from 'svelte/transition'
|
||||
import type { MDNSStatus, MDNSServiceItem, MDNSServiceQuery } from '$lib/types/models'
|
||||
import { compareIp } from '$lib/utilities'
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '$lib/api'
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte'
|
||||
import { AP, Home, MAC, Devices } from '$lib/components/icons'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import StatusItem from '$lib/components/StatusItem.svelte'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
import { slide } from 'svelte/transition'
|
||||
import type { MDNSStatus, MDNSServiceItem, MDNSServiceQuery } from '$lib/types/models'
|
||||
import { compareIp } from '$lib/utilities'
|
||||
|
||||
let mdnsStatus: MDNSStatus | undefined = $state()
|
||||
let services: MDNSServiceItem[] = $state([])
|
||||
let isLoading = $state(false)
|
||||
let mdnsStatus: MDNSStatus | undefined = $state()
|
||||
let services: MDNSServiceItem[] = $state([])
|
||||
let isLoading = $state(false)
|
||||
|
||||
const getMDNSStatus = async () => {
|
||||
const result = await api.get<MDNSStatus>('/api/mdns/status')
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
const getMDNSStatus = async () => {
|
||||
const result = await api.get<MDNSStatus>('/api/mdns/status')
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
mdnsStatus = result.inner
|
||||
}
|
||||
mdnsStatus = result.inner
|
||||
}
|
||||
|
||||
const queryMDNSServices = async () => {
|
||||
isLoading = true
|
||||
const result = await api.post<MDNSServiceQuery>('/api/mdns/query', {
|
||||
service: 'http',
|
||||
protocol: 'tcp'
|
||||
const queryMDNSServices = async () => {
|
||||
isLoading = true
|
||||
const result = await api.post<MDNSServiceQuery>('/api/mdns/query', {
|
||||
service: 'http',
|
||||
protocol: 'tcp'
|
||||
})
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
}
|
||||
services = result.inner.services.sort((a, b) => compareIp(a.ip, b.ip))
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await getMDNSStatus()
|
||||
await queryMDNSServices()
|
||||
})
|
||||
if (result.isErr()) {
|
||||
console.error('Error:', result.inner)
|
||||
return
|
||||
|
||||
const triggerScan = async () => {
|
||||
await queryMDNSServices()
|
||||
}
|
||||
services = result.inner.services.sort((a, b) => compareIp(a.ip, b.ip))
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await getMDNSStatus()
|
||||
await queryMDNSServices()
|
||||
})
|
||||
|
||||
const triggerScan = async () => {
|
||||
await queryMDNSServices()
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsCard collapsible={false}>
|
||||
{#snippet icon()}
|
||||
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>MDNS</span>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-ring loading-xs"></span>
|
||||
{:else}
|
||||
Scan
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-x-auto">
|
||||
{#if mdnsStatus}
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}>
|
||||
<StatusItem icon={Home} title="IP Address" description={mdnsStatus.hostname} />
|
||||
{#snippet icon()}
|
||||
<AP class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span>MDNS</span>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<button class="btn btn-primary" onclick={triggerScan} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-ring loading-xs"></span>
|
||||
{:else}
|
||||
Scan
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-x-auto">
|
||||
{#if mdnsStatus}
|
||||
<div
|
||||
class="flex w-full flex-col space-y-1"
|
||||
transition:slide|local={{ duration: 300, easing: cubicOut }}
|
||||
>
|
||||
<StatusItem icon={Home} title="IP Address" description={mdnsStatus.hostname} />
|
||||
|
||||
<StatusItem icon={MAC} title="Instance" description={mdnsStatus.instance} />
|
||||
<StatusItem icon={MAC} title="Instance" description={mdnsStatus.instance} />
|
||||
|
||||
<StatusItem icon={Devices} title="Services" description={mdnsStatus.services.length} />
|
||||
<StatusItem
|
||||
icon={Devices}
|
||||
title="Services"
|
||||
description={mdnsStatus.services.length}
|
||||
/>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Ip address</th>
|
||||
<th>Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each services as service}
|
||||
<tr>
|
||||
<td><Devices class="h-6 w-6" /></td>
|
||||
<td>{service.name}</td>
|
||||
<td>{service.ip}</td>
|
||||
<td>{service.port}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Ip address</th>
|
||||
<th>Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each services as service}
|
||||
<tr>
|
||||
<td><Devices class="h-6 w-6" /></td>
|
||||
<td>{service.name}</td>
|
||||
<td>{service.ip}</td>
|
||||
<td>{service.port}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Wifi from './Wifi.svelte';
|
||||
import Wifi from './Wifi.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
|
||||
<Wifi />
|
||||
<Wifi />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
title: 'WiFi Station'
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
return {
|
||||
title: 'WiFi Station'
|
||||
}
|
||||
}) satisfies PageLoad
|
||||
|
||||
+121
-111
@@ -1,131 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { focusTrap } from 'svelte-focus-trap';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte';
|
||||
import type { NetworkItem, NetworkList } from '$lib/types/models';
|
||||
import { api } from '$lib/api';
|
||||
import { AP, Network, Reload, Cancel } from '$lib/components/icons';
|
||||
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals';
|
||||
import { focusTrap } from 'svelte-focus-trap'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
|
||||
import type { NetworkItem, NetworkList } from '$lib/types/models'
|
||||
import { api } from '$lib/api'
|
||||
import { AP, Network, Reload, Cancel } from '$lib/components/icons'
|
||||
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
|
||||
|
||||
let { isOpen, storeNetwork }: ModalProps = $props();
|
||||
let { isOpen, storeNetwork }: ModalProps = $props()
|
||||
|
||||
const encryptionType = [
|
||||
'Open',
|
||||
'WEP',
|
||||
'WPA PSK',
|
||||
'WPA2 PSK',
|
||||
'WPA WPA2 PSK',
|
||||
'WPA2 Enterprise',
|
||||
'WPA3 PSK',
|
||||
'WPA2 WPA3 PSK',
|
||||
'WAPI PSK'
|
||||
]
|
||||
const encryptionType = [
|
||||
'Open',
|
||||
'WEP',
|
||||
'WPA PSK',
|
||||
'WPA2 PSK',
|
||||
'WPA WPA2 PSK',
|
||||
'WPA2 Enterprise',
|
||||
'WPA3 PSK',
|
||||
'WPA2 WPA3 PSK',
|
||||
'WAPI PSK'
|
||||
]
|
||||
|
||||
let listOfNetworks: NetworkItem[] = $state([])
|
||||
let listOfNetworks: NetworkItem[] = $state([])
|
||||
|
||||
let scanActive = $state(false)
|
||||
let scanActive = $state(false)
|
||||
|
||||
let pollingId: ReturnType<typeof setTimeout> | number
|
||||
let pollingId: ReturnType<typeof setTimeout> | number
|
||||
|
||||
async function scanNetworks() {
|
||||
scanActive = true
|
||||
await api.get('/api/wifi/scan')
|
||||
if ((await pollingResults()) == false) {
|
||||
pollingId = setInterval(() => pollingResults(), 1000)
|
||||
async function scanNetworks() {
|
||||
scanActive = true
|
||||
await api.get('/api/wifi/scan')
|
||||
if ((await pollingResults()) == false) {
|
||||
pollingId = setInterval(() => pollingResults(), 1000)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
async function pollingResults() {
|
||||
const result = await api.get<NetworkList>('/api/wifi/networks')
|
||||
if (result.isErr()) {
|
||||
console.error(`Error occurred while fetching: `, result.inner)
|
||||
return false
|
||||
async function pollingResults() {
|
||||
const result = await api.get<NetworkList>('/api/wifi/networks')
|
||||
if (result.isErr()) {
|
||||
console.error(`Error occurred while fetching: `, result.inner)
|
||||
return false
|
||||
}
|
||||
let response = result.inner
|
||||
listOfNetworks = response.networks
|
||||
scanActive = false
|
||||
if (listOfNetworks.length) {
|
||||
clearInterval(pollingId)
|
||||
pollingId = 0
|
||||
}
|
||||
return listOfNetworks.length
|
||||
}
|
||||
let response = result.inner
|
||||
listOfNetworks = response.networks
|
||||
scanActive = false
|
||||
if (listOfNetworks.length) {
|
||||
clearInterval(pollingId)
|
||||
pollingId = 0
|
||||
}
|
||||
return listOfNetworks.length
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scanNetworks()
|
||||
})
|
||||
onMount(() => {
|
||||
scanNetworks()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollingId) {
|
||||
clearInterval(pollingId)
|
||||
pollingId = 0
|
||||
}
|
||||
})
|
||||
onDestroy(() => {
|
||||
if (pollingId) {
|
||||
clearInterval(pollingId)
|
||||
pollingId = 0
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
role="dialog"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||
transition:fly={{ y: 50 }}
|
||||
use:exitBeforeEnter
|
||||
use:focusTrap>
|
||||
<div
|
||||
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
|
||||
<h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="overflow-y-auto">
|
||||
{#if scanActive}<div class="bg-base-100 flex flex-col items-center justify-center p-6">
|
||||
<AP class="text-secondary h-32 w-32 shrink animate-ping stroke-2" />
|
||||
<p class="mt-8 text-2xl">Scanning ...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="menu">
|
||||
{#each listOfNetworks as network, i}
|
||||
<li>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]"
|
||||
onclick={() => {
|
||||
storeNetwork(network.ssid)
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
|
||||
<Network class="text-primary-content h-auto w-full scale-75" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">{network.ssid}</div>
|
||||
<div class="text-sm opacity-75">
|
||||
Security: {encryptionType[network.encryption_type]}, Channel: {network.channel}
|
||||
role="dialog"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
|
||||
transition:fly={{ y: 50 }}
|
||||
use:exitBeforeEnter
|
||||
use:focusTrap
|
||||
>
|
||||
<div
|
||||
class="bg-base-100 rounded-box pointer-events-auto flex max-h-full min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
|
||||
>
|
||||
<h2 class="text-base-content text-start text-2xl font-bold">Scan Networks</h2>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="overflow-y-auto">
|
||||
{#if scanActive}<div
|
||||
class="bg-base-100 flex flex-col items-center justify-center p-6"
|
||||
>
|
||||
<AP class="text-secondary h-32 w-32 shrink animate-ping stroke-2" />
|
||||
<p class="mt-8 text-2xl">Scanning ...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<RssiIndicator showDBm={true} rssi={network.rssi} />
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-primary inline-flex flex-none items-center"
|
||||
disabled={scanActive}
|
||||
onclick={scanNetworks}>
|
||||
<Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
|
||||
</button>
|
||||
{:else}
|
||||
<ul class="menu">
|
||||
{#each listOfNetworks as network, i}
|
||||
<li>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-base-200 rounded-btn my-1 flex items-center space-x-3 hover:scale-[1.02] active:scale-[0.98]"
|
||||
onclick={() => {
|
||||
storeNetwork(network.ssid)
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
|
||||
<Network
|
||||
class="text-primary-content h-auto w-full scale-75"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">{network.ssid}</div>
|
||||
<div class="text-sm opacity-75">
|
||||
Security: {encryptionType[network.encryption_type]},
|
||||
Channel: {network.channel}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<RssiIndicator showDBm={true} rssi={network.rssi} />
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-primary inline-flex flex-none items-center"
|
||||
disabled={scanActive}
|
||||
onclick={scanNetworks}
|
||||
>
|
||||
<Reload class="mr-2 h-5 w-5" /><span>Scan again</span>
|
||||
</button>
|
||||
|
||||
<div class="grow"></div>
|
||||
<button
|
||||
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
|
||||
onclick={() => modals.close()}>
|
||||
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<button
|
||||
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
|
||||
onclick={() => modals.close()}
|
||||
>
|
||||
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+682
-586
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user