From b7ae17f3bff2e20c7b375611bfa6f7bb2b9a9221 Mon Sep 17 00:00:00 2001 From: Rune Harlyk Date: Wed, 8 May 2024 13:26:40 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=84=20Adds=20api=20service=20with=20up?= =?UTF-8?q?dates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/lib/api.ts | 73 ++++++++++++++ app/src/lib/components/UpdateIndicator.svelte | 94 ++++++++---------- app/src/lib/models.ts | 17 +++- app/src/lib/utilities/string-utilities.ts | 27 ++++++ app/src/routes/+layout.svelte | 20 ++-- app/src/routes/connections/mqtt/MQTT.svelte | 65 +++++-------- .../routes/connections/mqtt/MQTTConfig.svelte | 49 +++------- app/src/routes/connections/ntp/NTP.svelte | 65 ++++--------- app/src/routes/login.svelte | 42 ++++---- app/src/routes/statusbar.svelte | 11 +-- .../system/filesystem/FileSystem.svelte | 7 +- .../routes/system/status/SystemStatus.svelte | 78 +++------------ .../update/GithubFirmwareManager.svelte | 47 +++------ .../system/update/UploadFirmware.svelte | 22 ++--- app/src/routes/user/+page.svelte | 73 ++++---------- app/src/routes/wifi/ap/Accesspoint.svelte | 63 ++++-------- app/src/routes/wifi/sta/Scan.svelte | 49 ++++------ app/src/routes/wifi/sta/Wifi.svelte | 65 +++++-------- esp32/lib/ESP32-sveltekit/FeaturesService.cpp | 97 ++++++------------- 19 files changed, 391 insertions(+), 573 deletions(-) create mode 100644 app/src/lib/api.ts diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts new file mode 100644 index 0000000..dc70b1b --- /dev/null +++ b/app/src/lib/api.ts @@ -0,0 +1,73 @@ +import { user } from '$lib/stores/user'; +import { get } from 'svelte/store'; +import { Err, Ok, type Result } from './utilities'; + +export namespace api { + export function get(endpoint: string, params?: RequestInit) { + return sendRequest(endpoint, 'GET', null, params); + } + + export function post(endpoint: string, data?: unknown) { + return sendRequest(endpoint, 'POST', data); + } + + export function put(endpoint: string, data?: unknown) { + return sendRequest(endpoint, 'PUT', data); + } + + export function remove(endpoint: string) { + return sendRequest(endpoint, 'DELETE'); + } +} + +async function sendRequest( + endpoint: string, + method: string, + data?: unknown, + params?: RequestInit +): Promise> { + const user_token = get(user).bearer_token; + const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined; + + const request = { + ...params, + method, + body, + headers: { + ...params?.headers, + Authorization: user_token ? 'Bearer ' + user_token : 'Basic', + 'Content-Type': 'application/json' + } + }; + + let response; + + try { + response = await fetch(endpoint, request); + } catch (error) { + return Err.new(new Error(), 'An error has occurred'); + } + + const isResponseOk = response.status >= 200 && response.status < 400; + if (!isResponseOk) { + if (response.status === 401) { + return Err.new(new ApiError(response), 'User was not authorized'); + } + return Err.new(new ApiError(response), 'An error has occurred'); + } + + const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type'); + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + return Ok.new(data as TResponse); + } else { + // Handle empty object as response + return Ok.new(null as TResponse); + } +} + +export class ApiError extends Error { + constructor(public readonly response: Response) { + super(`${response.status}`); + } +} diff --git a/app/src/lib/components/UpdateIndicator.svelte b/app/src/lib/components/UpdateIndicator.svelte index 30641be..c5cc56c 100644 --- a/app/src/lib/components/UpdateIndicator.svelte +++ b/app/src/lib/components/UpdateIndicator.svelte @@ -10,6 +10,8 @@ import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'; import { compareVersions } from 'compare-versions'; import { onMount } from 'svelte'; + import { api } from '$lib/api'; + import type { GithubRelease } from '$lib/models'; export let update = false; @@ -17,67 +19,55 @@ let firmwareDownloadLink: string; async function getGithubAPI() { - try { - const response = await fetch( - 'https://api.github.com/repos/' + $page.data.github + '/releases/latest', - { - method: 'GET', - headers: { - accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28' - } - } - ); - const results = await response.json(); - if (results.message == "Not Found") { - console.error('Error: Could not find releases in the repository'); - return; + 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/latest`, {headers}) + if (result.inner.message === "404" || result.inner.message == "Not Found") { + console.warn('Error: Could not find releases in the repository'); + return + } + if (result.isErr()) { + console.error('Error:', result.inner); + return + } + + const results = result.inner; + update = false; + firmwareVersion = ''; + + if (compareVersions(results.tag_name, $page.data.features.firmware_version) === 1) { + // iterate over assets and find the correct one + for (let i = 0; i < results.assets.length; i++) { + // check if the asset is of type *.bin + if ( + results.assets[i].name.includes('.bin') && + results.assets[i].name.includes($page.data.features.firmware_built_target) + ) { + update = true; + firmwareVersion = results.tag_name; + firmwareDownloadLink = results.assets[i].browser_download_url; + notifications.info('Firmware update available.', 5000); + } } - - update = false; - firmwareVersion = ''; - - if (compareVersions(results.tag_name, $page.data.features.firmware_version) === 1) { - // iterate over assets and find the correct one - for (let i = 0; i < results.assets.length; i++) { - // check if the asset is of type *.bin - if ( - results.assets[i].name.includes('.bin') && - results.assets[i].name.includes($page.data.features.firmware_built_target) - ) { - update = true; - firmwareVersion = results.tag_name; - firmwareDownloadLink = results.assets[i].browser_download_url; - notifications.info('Firmware update available.', 5000); - } - } - } - } catch (error) { - console.error('Error:', error); - } + } } async function postGithubDownload(url: string) { - try { - const apiResponse = await fetch('/api/downloadUpdate', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ download_url: url }) - }); - } catch (error) { - console.error('Error:', error); - } + const result = await api.post('/api/downloadUpdate', { download_url: url }); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } } - onMount(() => { + onMount(async () => { if ($page.data.features.download_firmware && (!$page.data.features.security || $user.admin)) { - getGithubAPI(); + await getGithubAPI(); const interval = setInterval( async () => { - getGithubAPI(); + await getGithubAPI(); }, 60 * 60 * 1000 ); // once per hour diff --git a/app/src/lib/models.ts b/app/src/lib/models.ts index eb9c886..2904f37 100644 --- a/app/src/lib/models.ts +++ b/app/src/lib/models.ts @@ -7,6 +7,17 @@ export interface ControllerInput { speed: number; } +export type GithubRelease = { + message: string; + tag_name: string; + assets: Array<{ + name: string; + browser_download_url: string; + }>; +}; + +export type JWT = { access_token: string }; + export type angles = number[] | Int16Array; export type WifiStatus = { @@ -26,7 +37,11 @@ export type WifiStatus = { export type WifiSettings = { hostname: string; priority_RSSI: boolean; - wifi_networks: networkItem[]; + wifi_networks: NetworkItem[]; +}; + +export type NetworkList = { + networks: NetworkItem[]; }; export type KnownNetworkItem = { diff --git a/app/src/lib/utilities/string-utilities.ts b/app/src/lib/utilities/string-utilities.ts index 0ffc397..0f62434 100644 --- a/app/src/lib/utilities/string-utilities.ts +++ b/app/src/lib/utilities/string-utilities.ts @@ -7,3 +7,30 @@ export const humanFileSize = (size: number): string => { export const capitalize = (str: string): string => { return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); }; + +export const convertSeconds = (seconds: number) => { + // Calculate the number of seconds, minutes, hours, and days + let minutes = Math.floor(seconds / 60); + let hours = Math.floor(minutes / 60); + let days = Math.floor(hours / 24); + + // Calculate the remaining hours, minutes, and seconds + hours = hours % 24; + minutes = minutes % 60; + seconds = seconds % 60; + + // Create the formatted string + let result = ''; + if (days > 0) { + result += days + ' day' + (days > 1 ? 's' : '') + ' '; + } + if (hours > 0) { + result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '; + } + if (minutes > 0) { + result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '; + } + result += seconds + ' second' + (seconds > 1 ? 's' : ''); + + return result; +}; \ No newline at end of file diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte index 2f823c1..4d9ecbb 100644 --- a/app/src/routes/+layout.svelte +++ b/app/src/routes/+layout.svelte @@ -15,6 +15,7 @@ import Login from './login.svelte'; import { ModesEnum, mode, outControllerData, servoAngles, servoAnglesOut, socket } from '$lib/stores'; import type { Analytics, Battery, DownloadOTA } from '$lib/types/models'; + import { api } from '$lib/api'; onMount(async () => { if ($user.bearer_token !== '') { @@ -64,20 +65,11 @@ }; async function validateUser(userdata: userProfile) { - try { - const response = await fetch('/api/verifyAuthorization', { - method: 'GET', - headers: { - Authorization: 'Bearer ' + userdata.bearer_token, - 'Content-Type': 'application/json' - } - }); - if (response.status !== 200) { - user.invalidate(); - } - } catch (error) { - console.error('Error:', error); - } + const result = await api.get('/api/verifyAuthorization') + if (result.isErr()){ + user.invalidate(); + console.error('Error:', result.inner); + } } const handleOpen = () => { diff --git a/app/src/routes/connections/mqtt/MQTT.svelte b/app/src/routes/connections/mqtt/MQTT.svelte index 71baa02..4c5ef9e 100644 --- a/app/src/routes/connections/mqtt/MQTT.svelte +++ b/app/src/routes/connections/mqtt/MQTT.svelte @@ -12,6 +12,7 @@ import MQTT from '~icons/tabler/topology-star-3'; import Client from '~icons/tabler/robot'; import type { MQTTSettings, MQTTStatus } from '$lib/models'; + import { api } from '$lib/api'; let mqttSettings: MQTTSettings; let mqttStatus: MQTTStatus; @@ -19,34 +20,22 @@ let formField: any; async function getMQTTStatus() { - try { - const response = await fetch('/api/mqttStatus', { - method: 'GET', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - } - }); - mqttStatus = await response.json(); - } catch (error) { - console.error('Error:', error); - } + const result = await api.get('/api/mqttStatus'); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } + mqttStatus = result.inner return mqttStatus; } async function getMQTTSettings() { - try { - const response = await fetch('/api/mqttSettings', { - method: 'GET', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - } - }); - mqttSettings = await response.json(); - } catch (error) { - console.error('Error:', error); - } + const result = await api.get('/api/mqttSettings'); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } + mqttSettings = result.inner return mqttSettings; } @@ -70,25 +59,15 @@ }; async function postMQTTSettings(data: MQTTSettings) { - try { - const response = await fetch('/api/mqttSettings', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - if (response.status == 200) { - notifications.success('MQTT settings updated.', 3000); - mqttSettings = await response.json(); - } else { - notifications.error('User not authorized.', 3000); - } - } catch (error) { - console.error('Error:', error); - } - return; + const result = await api.post('/api/mqttSettings', data); + if (result.isErr()){ + console.error('Error:', result.inner); + notifications.error('User not authorized.', 3000); + return + } + notifications.success('MQTT settings updated.', 3000); + mqttSettings = result.inner + return mqttSettings; } function handleSubmitMQTT() { diff --git a/app/src/routes/connections/mqtt/MQTTConfig.svelte b/app/src/routes/connections/mqtt/MQTTConfig.svelte index 6fe56be..1d5eacb 100644 --- a/app/src/routes/connections/mqtt/MQTTConfig.svelte +++ b/app/src/routes/connections/mqtt/MQTTConfig.svelte @@ -2,32 +2,24 @@ import { slide } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; import SettingsCard from '$lib/components/SettingsCard.svelte'; - import { user } from '$lib/stores/user'; - import { page } from '$app/stores'; import { notifications } from '$lib/components/toasts/notifications'; import Spinner from '$lib/components/Spinner.svelte'; import MQTT from '~icons/tabler/topology-star-3'; import Info from '~icons/tabler/info-circle'; import type { BrokerSettings } from '$lib/types/models'; + import { api } from '$lib/api'; let brokerSettings: BrokerSettings; let formField: any; async function getBrokerSettings() { - try { - const response = await fetch('/api/brokerSettings', { - method: 'GET', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - } - }); - brokerSettings = await response.json(); - } catch (error) { - console.error('Error:', error); - } - return; + const result = await api.get('/api/brokerSettings'); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } + brokerSettings = result.inner } let formErrors = { @@ -37,25 +29,14 @@ }; async function postBrokerSettings() { - try { - const response = await fetch('/api/brokerSettings', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(brokerSettings) - }); - if (response.status == 200) { - notifications.success('Broker settings updated.', 3000); - brokerSettings = await response.json(); - } else { - notifications.error('User not authorized.', 3000); - } - } catch (error) { - console.error('Error:', error); - } - return; + const result = await api.post('/api/brokerSettings', brokerSettings); + if (result.isErr()){ + console.error('Error:', result.inner); + notifications.error('User not authorized.', 3000); + return + } + notifications.success('Broker settings updated.', 3000); + brokerSettings = result.inner } function handleSubmitBroker() { diff --git a/app/src/routes/connections/ntp/NTP.svelte b/app/src/routes/connections/ntp/NTP.svelte index d95d867..4ce3299 100644 --- a/app/src/routes/connections/ntp/NTP.svelte +++ b/app/src/routes/connections/ntp/NTP.svelte @@ -15,40 +15,27 @@ import UTC from '~icons/tabler/clock-pin'; import Stopwatch from '~icons/tabler/24-hours'; import type { NTPSettings, NTPStatus } from '$lib/types/models'; + import { api } from '$lib/api'; let ntpSettings: NTPSettings; let ntpStatus: NTPStatus; async function getNTPStatus() { - try { - const response = await fetch('/api/ntpStatus', { - method: 'GET', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - } - }); - ntpStatus = await response.json(); - } catch (error) { - console.error('Error:', error); - } - return; + const result = await api.get('/api/ntpStatus'); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } + ntpStatus = result.inner } async function getNTPSettings() { - try { - const response = await fetch('/api/ntpSettings', { - method: 'GET', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - } - }); - ntpSettings = await response.json(); - } catch (error) { - console.error('Error:', error); - } - return; + const result = await api.get('/api/ntpSettings'); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } + ntpSettings = result.inner } const interval = setInterval(async () => { @@ -70,25 +57,13 @@ }; async function postNTPSettings(data: NTPSettings) { - try { - const response = await fetch('/api/ntpSettings', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - if (response.status == 200) { - notifications.success('Security settings updated.', 3000); - ntpSettings = await response.json(); - } else { - notifications.error('User not authorized.', 3000); - } - } catch (error) { - console.error('Error:', error); - } + const result = await api.post('/api/ntpSettings', data); + if (result.isErr()){ + notifications.error('User not authorized.', 3000); + console.error('Error:', result.inner); + return + } + ntpSettings = result.inner } function handleSubmitNTP() { diff --git a/app/src/routes/login.svelte b/app/src/routes/login.svelte index 7c2724a..a254d98 100644 --- a/app/src/routes/login.svelte +++ b/app/src/routes/login.svelte @@ -5,6 +5,8 @@ import { notifications } from '$lib/components/toasts/notifications'; import { fade, fly } from 'svelte/transition'; import Login from '~icons/tabler/login'; + import { api } from '$lib/api'; + import type { JWT } from '$lib/models'; type SignInData = { password: string; @@ -19,31 +21,21 @@ let token = { access_token: '' }; async function signInUser(data: SignInData) { - try { - const response = await fetch('/api/signIn', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - if (response.status === 200) { - token = await response.json(); - user.init(token.access_token); - let username = $user.username; - notifications.success('User ' + username + ' signed in', 5000); - } else { - username = ''; - password = ''; - notifications.error('Wrong Username or Password!', 5000); - loginFailed = true; - setTimeout(() => { - loginFailed = false; - }, 1500); - } - } catch (error) { - console.error('Error:', error); - } + const result = await api.post('/api/signIn', data) + if (result.isErr()){ + username = ''; + password = ''; + notifications.error('Wrong Username or Password!', 5000); + loginFailed = true; + setTimeout(() => { + loginFailed = false; + }, 1500); + return + } + token = result.inner; + user.init(token.access_token); + username = $user.username; + notifications.success('User ' + username + ' signed in', 5000); } diff --git a/app/src/routes/statusbar.svelte b/app/src/routes/statusbar.svelte index dd6c6ed..b13ca17 100644 --- a/app/src/routes/statusbar.svelte +++ b/app/src/routes/statusbar.svelte @@ -2,7 +2,6 @@ import { page } from '$app/stores'; import { telemetry } from '$lib/stores/telemetry'; import { openModal, closeModal } from 'svelte-modals'; - import { user } from '$lib/stores/user'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import WiFiOff from '~icons/tabler/wifi-off'; import Hamburger from '~icons/tabler/menu-2'; @@ -13,15 +12,9 @@ import UpdateIndicator from '$lib/components/UpdateIndicator.svelte'; import MdiWeatherSunny from '~icons/mdi/weather-sunny'; import MdiMoonAndStars from '~icons/mdi/moon-and-stars'; + import { api } from '$lib/api'; - async function postSleep() { - const response = await fetch('/api/sleep', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic' - } - }); - } + const postSleep = async () => await api.post('/api/sleep') function confirmSleep() { openModal(ConfirmDialog, { diff --git a/app/src/routes/system/filesystem/FileSystem.svelte b/app/src/routes/system/filesystem/FileSystem.svelte index 34f70eb..b353d28 100644 --- a/app/src/routes/system/filesystem/FileSystem.svelte +++ b/app/src/routes/system/filesystem/FileSystem.svelte @@ -3,11 +3,12 @@ import Spinner from "$lib/components/Spinner.svelte"; import FolderIcon from '~icons/mdi/folder-outline'; import Folder from "./Folder.svelte"; + import { api } from "$lib/api"; const getFiles = async () => { - const response = await fetch('/api/files/list'); - if (response.ok) { - return response.json(); + const result = await api.get('/api/files/list') + if (result.isOk()) { + return result.inner; } }; diff --git a/app/src/routes/system/status/SystemStatus.svelte b/app/src/routes/system/status/SystemStatus.svelte index e8ec267..596c9c6 100644 --- a/app/src/routes/system/status/SystemStatus.svelte +++ b/app/src/routes/system/status/SystemStatus.svelte @@ -26,25 +26,25 @@ import SDK from '~icons/tabler/sdk'; import type { SystemInformation, Analytics } from '$lib/types/models'; import { socket } from '$lib/stores/socket'; + import { api } from '$lib/api'; + import { convertSeconds } from '$lib/utilities'; let systemInformation: SystemInformation; async function getSystemStatus() { - try { - const response = await fetch('/api/systemStatus', { - method: 'GET', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic', - 'Content-Type': 'application/json' - } - }); - systemInformation = await response.json(); - } catch (error) { - console.log('Error:', error); - } + const result = await api.get('/api/systemStatus'); + if (result.isErr()){ + console.error('Error:', result.inner); + return + } + systemInformation = result.inner return systemInformation; } + const postFactoryReset = async () => await api.post('/api/factoryReset') + + const postSleep = async () => await api.post('api/sleep') + onMount(() => socket.on('analytics', handleSystemData)); onDestroy(() => socket.off('analytics', handleSystemData)); @@ -52,14 +52,7 @@ const handleSystemData = (data: Analytics) => (systemInformation = { ...systemInformation, ...data }); - async function postRestart() { - const response = await fetch('/api/restart', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic' - } - }); - } + const postRestart = async () => await api.post('/api/restart'); function confirmRestart() { openModal(ConfirmDialog, { @@ -76,15 +69,6 @@ }); } - async function postFactoryReset() { - const response = await fetch('/api/factoryReset', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic' - } - }); - } - function confirmReset() { openModal(ConfirmDialog, { title: 'Confirm Factory Reset', @@ -100,15 +84,6 @@ }); } - async function postSleep() { - const response = await fetch('/api/sleep', { - method: 'POST', - headers: { - Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic' - } - }); - } - function confirmSleep() { openModal(ConfirmDialog, { title: 'Confirm Going to Sleep', @@ -123,33 +98,6 @@ } }); } - - function convertSeconds(seconds: number) { - // Calculate the number of seconds, minutes, hours, and days - let minutes = Math.floor(seconds / 60); - let hours = Math.floor(minutes / 60); - let days = Math.floor(hours / 24); - - // Calculate the remaining hours, minutes, and seconds - hours = hours % 24; - minutes = minutes % 60; - seconds = seconds % 60; - - // Create the formatted string - let result = ''; - if (days > 0) { - result += days + ' day' + (days > 1 ? 's' : '') + ' '; - } - if (hours > 0) { - result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '; - } - if (minutes > 0) { - result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '; - } - result += seconds + ' second' + (seconds > 1 ? 's' : ''); - - return result; - } diff --git a/app/src/routes/system/update/GithubFirmwareManager.svelte b/app/src/routes/system/update/GithubFirmwareManager.svelte index b47f71e..59e5cf2 100644 --- a/app/src/routes/system/update/GithubFirmwareManager.svelte +++ b/app/src/routes/system/update/GithubFirmwareManager.svelte @@ -1,5 +1,4 @@