Adds almost complete use of ESP32-sveltekit template

This commit is contained in:
Rune Harlyk
2024-03-29 01:40:08 +01:00
committed by Rune Harlyk
parent 290f678253
commit cde36ffda5
82 changed files with 7170 additions and 212 deletions
BIN
View File
Binary file not shown.
+11 -6
View File
@@ -17,16 +17,16 @@
"devDependencies": {
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/tabler": "^1.1.109",
"@iconify/json": "^2.2.196",
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^8.56.0",
"@types/three": "^0.162.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"autoprefixer": "^10.4.19",
"daisyui": "^4.9.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
@@ -35,6 +35,7 @@
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"svelte-focus-trap": "^1.2.0",
"tailwindcss": "^3.4.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
@@ -44,12 +45,16 @@
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@types/three": "^0.162.0",
"chart.js": "^4.4.2",
"compare-versions": "^6.1.0",
"daisyui": "^4.9.0",
"jwt-decode": "^4.0.0",
"nipplejs": "^0.10.1",
"svelte-dnd-list": "^0.1.8",
"svelte-modals": "^1.3.0",
"three": "^0.162.0",
"urdf-loader": "^0.12.1",
"uzip": "^0.20201231.0",
"xacro-parser": "^0.3.9"
}
}
}
+155 -47
View File
@@ -5,15 +5,27 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@sveltejs/adapter-static':
specifier: ^3.0.1
version: 3.0.1(@sveltejs/kit@2.0.0)
'@types/three':
specifier: ^0.162.0
version: 0.162.0
chart.js:
specifier: ^4.4.2
version: 4.4.2
compare-versions:
specifier: ^6.1.0
version: 6.1.0
daisyui:
specifier: ^4.9.0
version: 4.9.0(postcss@8.4.38)
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
nipplejs:
specifier: ^0.10.1
version: 0.10.1
svelte-dnd-list:
specifier: ^0.1.8
version: 0.1.8
svelte-modals:
specifier: ^1.3.0
version: 1.3.0(svelte@4.2.7)
three:
specifier: ^0.162.0
version: 0.162.0
@@ -34,24 +46,27 @@ devDependencies:
'@iconify-json/tabler':
specifier: ^1.1.109
version: 1.1.109
'@iconify/json':
specifier: ^2.2.196
version: 2.2.196
'@playwright/test':
specifier: ^1.28.1
version: 1.28.1
'@sveltejs/adapter-auto':
specifier: ^3.0.0
version: 3.0.0(@sveltejs/kit@2.0.0)
version: 3.0.0(@sveltejs/kit@2.5.5)
'@sveltejs/adapter-static':
specifier: ^3.0.1
version: 3.0.1(@sveltejs/kit@2.5.5)
'@sveltejs/kit':
specifier: ^2.0.0
version: 2.0.0(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3)
specifier: ^2.5.5
version: 2.5.5(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3)
'@sveltejs/vite-plugin-svelte':
specifier: ^3.0.0
version: 3.0.0(svelte@4.2.7)(vite@5.0.3)
'@types/eslint':
specifier: ^8.56.0
version: 8.56.0
'@types/three':
specifier: ^0.162.0
version: 0.162.0
'@typescript-eslint/eslint-plugin':
specifier: ^7.0.0
version: 7.0.0(@typescript-eslint/parser@7.0.0)(eslint@8.56.0)(typescript@5.0.2)
@@ -61,9 +76,6 @@ devDependencies:
autoprefixer:
specifier: ^10.4.19
version: 10.4.19(postcss@8.4.38)
daisyui:
specifier: ^4.9.0
version: 4.9.0(postcss@8.4.38)
eslint:
specifier: ^8.56.0
version: 8.56.0
@@ -88,6 +100,9 @@ devDependencies:
svelte-check:
specifier: ^3.6.0
version: 3.6.0(postcss@8.4.38)(svelte@4.2.7)
svelte-focus-trap:
specifier: ^1.2.0
version: 1.2.0
tailwindcss:
specifier: ^3.4.3
version: 3.4.3
@@ -149,6 +164,7 @@ packages:
cpu: [ppc64]
os: [aix]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm64@0.19.12:
@@ -157,6 +173,7 @@ packages:
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.19.12:
@@ -165,6 +182,7 @@ packages:
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.19.12:
@@ -173,6 +191,7 @@ packages:
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.19.12:
@@ -181,6 +200,7 @@ packages:
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.19.12:
@@ -189,6 +209,7 @@ packages:
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.19.12:
@@ -197,6 +218,7 @@ packages:
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.19.12:
@@ -205,6 +227,7 @@ packages:
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.19.12:
@@ -213,6 +236,7 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.19.12:
@@ -221,6 +245,7 @@ packages:
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.19.12:
@@ -229,6 +254,7 @@ packages:
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.19.12:
@@ -237,6 +263,7 @@ packages:
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.19.12:
@@ -245,6 +272,7 @@ packages:
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.19.12:
@@ -253,6 +281,7 @@ packages:
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.19.12:
@@ -261,6 +290,7 @@ packages:
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.19.12:
@@ -269,6 +299,7 @@ packages:
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.19.12:
@@ -277,6 +308,7 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.19.12:
@@ -285,6 +317,7 @@ packages:
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.19.12:
@@ -293,6 +326,7 @@ packages:
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.19.12:
@@ -301,6 +335,7 @@ packages:
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.19.12:
@@ -309,6 +344,7 @@ packages:
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.19.12:
@@ -317,6 +353,7 @@ packages:
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.19.12:
@@ -325,6 +362,7 @@ packages:
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@eslint-community/eslint-utils@4.4.0(eslint@8.56.0):
@@ -396,13 +434,6 @@ packages:
'@iconify/types': 2.0.0
dev: true
/@iconify/json@2.2.196:
resolution: {integrity: sha512-hRZ0pq77N+mkAbZvFi/pfsKcspA8PyGSASc6zQoq6n/RSLxb8xAgORatVHyDl0ow7shcS+dvyiZI8xmr6yI2WA==}
dependencies:
'@iconify/types': 2.0.0
pathe: 1.1.2
dev: true
/@iconify/types@2.0.0:
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
dev: true
@@ -465,6 +496,10 @@ packages:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
/@kurkle/color@0.3.2:
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -504,12 +539,14 @@ packages:
/@polka/url@1.0.0-next.25:
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
dev: true
/@rollup/rollup-android-arm-eabi@4.13.1:
resolution: {integrity: sha512-4C4UERETjXpC4WpBXDbkgNVgHyWfG3B/NKY46e7w5H134UDOFqUJKpsLm0UYmuupW+aJmRgeScrDNfvZ5WV80A==}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-android-arm64@4.13.1:
@@ -517,6 +554,7 @@ packages:
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-darwin-arm64@4.13.1:
@@ -524,6 +562,7 @@ packages:
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-darwin-x64@4.13.1:
@@ -531,6 +570,7 @@ packages:
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-arm-gnueabihf@4.13.1:
@@ -538,6 +578,7 @@ packages:
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-arm64-gnu@4.13.1:
@@ -545,6 +586,7 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-arm64-musl@4.13.1:
@@ -552,6 +594,7 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-riscv64-gnu@4.13.1:
@@ -559,6 +602,7 @@ packages:
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-s390x-gnu@4.13.1:
@@ -566,6 +610,7 @@ packages:
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-x64-gnu@4.13.1:
@@ -573,6 +618,7 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-linux-x64-musl@4.13.1:
@@ -580,6 +626,7 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-win32-arm64-msvc@4.13.1:
@@ -587,6 +634,7 @@ packages:
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-win32-ia32-msvc@4.13.1:
@@ -594,6 +642,7 @@ packages:
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@rollup/rollup-win32-x64-msvc@4.13.1:
@@ -601,31 +650,32 @@ packages:
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@sinclair/typebox@0.27.8:
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
dev: true
/@sveltejs/adapter-auto@3.0.0(@sveltejs/kit@2.0.0):
/@sveltejs/adapter-auto@3.0.0(@sveltejs/kit@2.5.5):
resolution: {integrity: sha512-UNWSs/rOReBRfI/xFwSO2WYF1a7PT74SrWOHJmSNLY3Lq+zbH0uuvnlP+TmrTUBvOTkou3WJDjL6lK3n6aOUgQ==}
peerDependencies:
'@sveltejs/kit': ^2.0.0
dependencies:
'@sveltejs/kit': 2.0.0(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3)
'@sveltejs/kit': 2.5.5(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3)
import-meta-resolve: 4.0.0
dev: true
/@sveltejs/adapter-static@3.0.1(@sveltejs/kit@2.0.0):
/@sveltejs/adapter-static@3.0.1(@sveltejs/kit@2.5.5):
resolution: {integrity: sha512-6lMvf7xYEJ+oGeR5L8DFJJrowkefTK6ZgA4JiMqoClMkKq0s6yvsd3FZfCFvX1fQ0tpCD7fkuRVHsnUVgsHyNg==}
peerDependencies:
'@sveltejs/kit': ^2.0.0
dependencies:
'@sveltejs/kit': 2.0.0(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3)
dev: false
'@sveltejs/kit': 2.5.5(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3)
dev: true
/@sveltejs/kit@2.0.0(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3):
resolution: {integrity: sha512-/GFxvit+q7PztRbgGTFXhVB6jvb0fZSeWuz5f4siQ2r/5BVhxYh7++Bw3/ZUjiOuyoZFiNBmOPcRNQbkzEce0g==}
/@sveltejs/kit@2.5.5(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3):
resolution: {integrity: sha512-ULe3PB00q4+wYRL+IS5FDPsCEVnhEITofm7b9Yz8malcH3r1SAnW/JJ6T13hIMeu8QNRIuVQWo+P4+2VklbnLQ==}
engines: {node: '>=18.13'}
hasBin: true
requiresBuild: true
@@ -639,15 +689,17 @@ packages:
cookie: 0.6.0
devalue: 4.3.2
esm-env: 1.0.0
import-meta-resolve: 4.0.0
kleur: 4.1.5
magic-string: 0.30.8
mrmime: 1.0.1
mrmime: 2.0.0
sade: 1.8.1
set-cookie-parser: 2.6.0
sirv: 2.0.4
svelte: 4.2.7
tiny-glob: 0.2.9
vite: 5.0.3
dev: true
/@sveltejs/vite-plugin-svelte-inspector@2.0.0(@sveltejs/vite-plugin-svelte@3.0.0)(svelte@4.2.7)(vite@5.0.3):
resolution: {integrity: sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==}
@@ -663,6 +715,7 @@ packages:
vite: 5.0.3
transitivePeerDependencies:
- supports-color
dev: true
/@sveltejs/vite-plugin-svelte@3.0.0(svelte@4.2.7)(vite@5.0.3):
resolution: {integrity: sha512-Th0nupxk8hl5Rcg9jm+1xWylwco4bSUAvutWxM4W4bjOAollpXLmrYqSSnYo9pPbZOO6ZGRm6sSqYa/v1d/Saw==}
@@ -682,13 +735,15 @@ packages:
vitefu: 0.2.5(vite@5.0.3)
transitivePeerDependencies:
- supports-color
dev: true
/@tweenjs/tween.js@23.1.1:
resolution: {integrity: sha512-ZpboH7pCPPeyBWKf8c7TJswtCEQObFo3bOBYalm99NzZarATALYCo5OhbCa/n4RQyJyHfhkdx+hNrdL5ByFYDw==}
dev: false
dev: true
/@types/cookie@0.6.0:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
dev: true
/@types/eslint@8.56.0:
resolution: {integrity: sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==}
@@ -720,7 +775,7 @@ packages:
/@types/stats.js@0.17.3:
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
dev: false
dev: true
/@types/three@0.162.0:
resolution: {integrity: sha512-0j5yZcVukVIhrhSIC7+LmBPkkMoMuEJ1AfYBZfgNytdYqYREMuiyXWhYOMeZLBElTEAlJIZn7r2W3vqTIgjWlg==}
@@ -730,11 +785,11 @@ packages:
'@types/webxr': 0.5.14
fflate: 0.6.10
meshoptimizer: 0.18.1
dev: false
dev: true
/@types/webxr@0.5.14:
resolution: {integrity: sha512-UEMMm/Xn3DtEa+gpzUrOcDj+SJS1tk5YodjwOxcqStNhCfPcwgyC5Srg2ToVKyg2Fhq16Ffpb0UWUQHqoT9AMA==}
dev: false
dev: true
/@typescript-eslint/eslint-plugin@7.0.0(@typescript-eslint/parser@7.0.0)(eslint@8.56.0)(typescript@5.0.2):
resolution: {integrity: sha512-M72SJ0DkcQVmmsbqlzc6EJgb/3Oz2Wdm6AyESB4YkGgCxP8u5jt5jn4/OBMPK3HLOxcttZq5xbBBU7e2By4SZQ==}
@@ -1075,7 +1130,6 @@ packages:
/camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
dev: true
/caniuse-lite@1.0.30001600:
resolution: {integrity: sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==}
@@ -1102,6 +1156,13 @@ packages:
supports-color: 7.2.0
dev: true
/chart.js@4.4.2:
resolution: {integrity: sha512-6GD7iKwFpP5kbSD4MeRRRlTnQvxfQREy36uEtm1hzHzcOqwWx0YEHuspuoNlslu+nciLIB7fjjsHkUv/FzFcOg==}
engines: {pnpm: '>=8'}
dependencies:
'@kurkle/color': 0.3.2
dev: false
/check-error@1.0.3:
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
dependencies:
@@ -1155,6 +1216,10 @@ packages:
engines: {node: '>= 6'}
dev: true
/compare-versions@6.1.0:
resolution: {integrity: sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==}
dev: false
/concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
@@ -1162,6 +1227,7 @@ packages:
/cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
dev: true
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
@@ -1177,7 +1243,7 @@ packages:
dependencies:
cssesc: 3.0.0
fastparse: 1.1.2
dev: true
dev: false
/css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
@@ -1190,12 +1256,11 @@ packages:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
dev: true
/culori@3.3.0:
resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
dev: false
/daisyui@4.9.0(postcss@8.4.38):
resolution: {integrity: sha512-9JsDx4E+30kPxThE+6yEwQokqg1957uwTx/skP2RE98fG6Ten6U+S9YXeQg1a3CI958aF5aOb0oEA+KZFfrZUA==}
@@ -1207,7 +1272,7 @@ packages:
postcss-js: 4.0.1(postcss@8.4.38)
transitivePeerDependencies:
- postcss
dev: true
dev: false
/debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
@@ -1219,6 +1284,7 @@ packages:
optional: true
dependencies:
ms: 2.1.2
dev: true
/deep-eql@4.1.3:
resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==}
@@ -1234,6 +1300,7 @@ packages:
/deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
dev: true
/dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
@@ -1246,6 +1313,7 @@ packages:
/devalue@4.3.2:
resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==}
dev: true
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@@ -1322,6 +1390,7 @@ packages:
'@esbuild/win32-arm64': 0.19.12
'@esbuild/win32-ia32': 0.19.12
'@esbuild/win32-x64': 0.19.12
dev: true
/escalade@3.1.2:
resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==}
@@ -1441,6 +1510,7 @@ packages:
/esm-env@1.0.0:
resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==}
dev: true
/espree@9.6.1:
resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
@@ -1539,7 +1609,7 @@ packages:
/fastparse@1.1.2:
resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==}
dev: true
dev: false
/fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
@@ -1549,7 +1619,7 @@ packages:
/fflate@0.6.10:
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
dev: false
dev: true
/file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
@@ -1607,6 +1677,7 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/function-bind@1.1.2:
@@ -1678,6 +1749,7 @@ packages:
/globalyzer@0.1.0:
resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==}
dev: true
/globby@11.1.0:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
@@ -1693,6 +1765,7 @@ packages:
/globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
dev: true
/graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1852,6 +1925,11 @@ packages:
resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==}
dev: true
/jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
dev: false
/keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
@@ -1861,6 +1939,7 @@ packages:
/kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
dev: true
/known-css-properties@0.29.0:
resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==}
@@ -1952,7 +2031,7 @@ packages:
/meshoptimizer@0.18.1:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
dev: false
dev: true
/micromatch@4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
@@ -2015,20 +2094,23 @@ packages:
ufo: 1.5.3
dev: true
/mousetrap@1.6.5:
resolution: {integrity: sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==}
dev: true
/mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
/mrmime@1.0.1:
resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==}
engines: {node: '>=10'}
dev: true
/mrmime@2.0.0:
resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==}
engines: {node: '>=10'}
dev: true
/ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
/mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -2253,7 +2335,6 @@ packages:
dependencies:
camelcase-css: 2.0.1
postcss: 8.4.38
dev: true
/postcss-load-config@3.1.4(postcss@8.4.38):
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
@@ -2453,6 +2534,7 @@ packages:
'@rollup/rollup-win32-ia32-msvc': 4.13.1
'@rollup/rollup-win32-x64-msvc': 4.13.1
fsevents: 2.3.3
dev: true
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2465,6 +2547,7 @@ packages:
engines: {node: '>=6'}
dependencies:
mri: 1.2.0
dev: true
/sander@0.5.1:
resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==}
@@ -2485,6 +2568,7 @@ packages:
/set-cookie-parser@2.6.0:
resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==}
dev: true
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -2518,6 +2602,7 @@ packages:
'@polka/url': 1.0.0-next.25
mrmime: 2.0.0
totalist: 3.0.1
dev: true
/slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
@@ -2657,6 +2742,10 @@ packages:
- sugarss
dev: true
/svelte-dnd-list@0.1.8:
resolution: {integrity: sha512-81Nt/niux7kf59lql0lxTAH0z8xwAxHdHC9dZT7MbfE32T6hgeLsLZ7RIBNAihQ040Io1KghqqPXD+k2viiKeA==}
dev: false
/svelte-eslint-parser@0.33.1(svelte@4.2.7):
resolution: {integrity: sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2674,6 +2763,12 @@ packages:
svelte: 4.2.7
dev: true
/svelte-focus-trap@1.2.0:
resolution: {integrity: sha512-/hIUHgKcFlewsQreq8v7DYNBkQe7rR0c94PNBOCsmeUwoIYAy6iXJ1pH0k3rWpjwZHKtUxeXbX1iRFlFhipbeg==}
dependencies:
mousetrap: 1.6.5
dev: true
/svelte-hmr@0.15.3(svelte@4.2.7):
resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==}
engines: {node: ^12.20 || ^14.13.1 || >= 16}
@@ -2681,6 +2776,15 @@ packages:
svelte: ^3.19.0 || ^4.0.0
dependencies:
svelte: 4.2.7
dev: true
/svelte-modals@1.3.0(svelte@4.2.7):
resolution: {integrity: sha512-b1Ylnyv9O6b7VYeWGJVToaVU2lw7GtErVwiEdojyfnOuZcrhNlQ5eDqbTrL3xyKz8j2VTy/QiGUl1lm/6SnQ2A==}
peerDependencies:
svelte: ^3.0.0 || ^4.0.0
dependencies:
svelte: 4.2.7
dev: false
/svelte-preprocess@5.1.3(postcss@8.4.38)(svelte@4.2.7)(typescript@5.4.3):
resolution: {integrity: sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==}
@@ -2805,6 +2909,7 @@ packages:
dependencies:
globalyzer: 0.1.0
globrex: 0.1.2
dev: true
/tinybench@2.6.0:
resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==}
@@ -2830,6 +2935,7 @@ packages:
/totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
dev: true
/ts-api-utils@1.3.0(typescript@5.0.2):
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
@@ -3013,6 +3119,7 @@ packages:
rollup: 4.13.1
optionalDependencies:
fsevents: 2.3.3
dev: true
/vitefu@0.2.5(vite@5.0.3):
resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==}
@@ -3023,6 +3130,7 @@ packages:
optional: true
dependencies:
vite: 5.0.3
dev: true
/vitest@1.2.0:
resolution: {integrity: sha512-Ixs5m7BjqvLHXcibkzKRQUvD/XLw0E3rvqaCMlrm/0LMsA0309ZqYvTlPzkhh81VlEyVZXFlwWnkhb6/UMtcaQ==}
+12 -1
View File
@@ -1,3 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
#three-gui-panel {
top: 64px;
right: 0px;
}
@media (max-width: 1023px) {
#three-gui-panel {
top: 48px;
}
}
+4
View File
@@ -0,0 +1,4 @@
export function daisyColor(name: string, opacity: number = 100) {
const color = getComputedStyle(document.documentElement).getPropertyValue(name);
return 'hsla(' + color + '/ ' + Math.min(Math.max(Math.round(opacity), 0), 100) + '%)';
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,27 @@
<script lang="ts">
import Battery0 from '~icons/tabler/battery';
import Battery25 from '~icons/tabler/battery-1';
import Battery50 from '~icons/tabler/battery-2';
import Battery75 from '~icons/tabler/battery-3';
import Battery100 from '~icons/tabler/battery-4';
import BatteryCharging from '~icons/tabler/battery-charging-2';
export let charging = false;
export let soc = 100;
</script>
<div class="tooltip tooltip-bottom" data-tip="{soc} %">
{#if charging}
<BatteryCharging class="{$$props.class || ''} -rotate-90 animate-pulse" />
{:else if soc > 75}
<Battery100 class="{$$props.class || ''} -rotate-90" />
{:else if soc > 55}
<Battery75 class="{$$props.class || ''} -rotate-90" />
{:else if soc > 30}
<Battery50 class="{$$props.class || ''} -rotate-90" />
{:else if soc > 5}
<Battery25 class="{$$props.class || ''} -rotate-90" />
{:else}
<Battery0 class="{$$props.class || ''} text-error -rotate-90 animate-pulse" />
{/if}
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import Down from '~icons/tabler/chevron-down';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function openCollapsible() {
open = !open;
if (open) {
dispatch('opened');
} else {
dispatch('closed');
}
}
export let open = false;
</script>
<div class="{$$props.class || ''} 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-4 text-xl font-medium">
<span class="inline-flex items-baseline">
<slot name="icon" />
<slot name="title" />
</span>
<button class="btn btn-circle btn-ghost btn-sm" on:click={() => openCollapsible()}>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open
? 'rotate-180'
: ''}"
/>
</button>
</div>
{#if open}
<div
class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<slot />
</div>
{/if}
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import { closeModal } from 'svelte-modals';
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import Cancel from '~icons/tabler/x';
import Check from '~icons/tabler/check';
// provided by <Modals />
export let isOpen: boolean;
export let title: string;
export let message: string;
export let onConfirm: any;
export let labels = {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check }
};
</script>
{#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
on:introstart
on:outroend
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">{title}</h2>
<div class="divider my-2" />
<p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2" />
<div class="flex justify-end gap-2">
<button class="btn btn-primary inline-flex items-center" on:click={closeModal}
><svelte:component this={labels.cancel.icon} class="mr-2 h-5 w-5" /><span
>{labels?.cancel.label}</span
></button
>
<button
class="btn btn-warning text-warning-content inline-flex items-center"
on:click={onConfirm}
><svelte:component this={labels?.confirm.icon} class="mr-2 h-5 w-5" /><span
>{labels?.confirm.label}</span
></button
>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,92 @@
<script lang="ts">
import { closeAllModals, onBeforeClose } from 'svelte-modals';
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { telemetry } from '$lib/stores/telemetry';
import Cancel from '~icons/tabler/x';
// provided by <Modals />
export let isOpen: boolean;
let updating = true;
let progress = 0;
$: if ($telemetry.download_ota.status == 'progress') {
progress = $telemetry.download_ota.progress;
}
$: if ($telemetry.download_ota.status == 'error') {
updating = false;
}
let message = 'Preparing ...';
let timerId: number;
$: if ($telemetry.download_ota.status == 'progress') {
message = 'Downloading ...';
} else if ($telemetry.download_ota.status == 'error') {
message = $telemetry.download_ota.error;
} else if ($telemetry.download_ota.status == 'finished') {
message = 'Restarting ...';
progress = 0;
// Reload page after 5 sec
timerId = setTimeout(() => {
closeAllModals();
location.reload();
}, 5000);
}
onBeforeClose(() => {
if (updating) {
// prevents modal from closing
return false;
} else {
$telemetry.download_ota.status = 'idle';
$telemetry.download_ota.error = '';
$telemetry.download_ota.progress = 0;
return true;
}
});
</script>
{#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
on:introstart
on:outroend
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">Updating Firmware</h2>
<div class="divider my-2" />
<div class="overflow-y-auto">
<div class="bg-base-100 flex flex-col items-center justify-center p-6">
{#if $telemetry.download_ota.status == 'progress'}
<progress class="progress progress-primary w-56" value={progress} max="100" />
{:else}
<progress class="progress progress-primary w-56" />
{/if}
<p class="mt-8 text-2xl">{message}</p>
</div>
</div>
<div class="divider my-2" />
<div class="flex flex-wrap justify-end gap-2">
<div class="flex-grow" />
<button
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
disabled={updating}
on:click={() => {
closeAllModals();
location.reload();
}}
>
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
>
</div>
</div>
</div>
{/if}
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { closeModal } from 'svelte-modals';
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import Check from '~icons/tabler/check';
// provided by <Modals />
export let isOpen: boolean;
export let title: string;
export let message: string;
export let onDismiss: any;
export let dismiss = { label: 'Dismiss', icon: Check };
</script>
{#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
on:introstart
on:outroend
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">{title}</h2>
<div class="divider my-2" />
<p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2" />
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
on:click={onDismiss}
><svelte:component this={dismiss.icon} class="mr-2 h-5 w-5" /><span>{dismiss.label}</span
></button
>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,56 @@
<script lang="ts">
let show = false;
$: type = show ? 'text' : 'password';
export let value = '';
export let id = '';
function handleInput(e: any) {
value = e.target.value;
}
</script>
<div class="relative">
<input {type} class="input input-bordered w-full" {value} on:input={handleInput} {id} />
<div class="absolute inset-y-0 right-0 flex items-center pr-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}"
on:click={() => (show = false)}
width="40"
height="40"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path
d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"
/>
<path d="M3 3l18 18" />
</svg>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}"
on:click={() => (show = true)}
width="40"
height="40"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
</div>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import WiFi from '~icons/tabler/wifi';
import WiFi0 from '~icons/tabler/wifi-0';
import WiFi1 from '~icons/tabler/wifi-1';
import WiFi2 from '~icons/tabler/wifi-2';
export let showDBm = false;
export let rssi_dbm = 0;
</script>
<div class="indicator">
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi_dbm} dBm
</span>
{/if}
{#if rssi_dbm >= -55}
<WiFi class={$$props.class || ''} />
{:else if rssi_dbm >= -75}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi2 class="absolute inset-0 h-full w-full" />
</div>
{:else if rssi_dbm >= -85}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi1 class="absolute inset-0 h-full w-full" />
</div>
{:else}
<div class="{$$props.class || ''} relative">
<WiFi class="absolute inset-0 h-full w-full opacity-30" />
<WiFi0 class="absolute inset-0 h-full w-full" />
</div>
{/if}
</div>
@@ -0,0 +1,56 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import Down from '~icons/tabler/chevron-down';
export let open = true;
export let collapsible = true;
</script>
{#if collapsible}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
>
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
>
<span class="inline-flex items-baseline">
<slot name="icon" />
<slot name="title" />
</span>
<button
class="btn btn-circle btn-ghost btn-sm"
on:click={() => {
open = !open;
}}
>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open
? 'rotate-180'
: ''}"
/>
</button>
</div>
{#if open}
<div
class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<slot />
</div>
{/if}
</div>
{:else}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
>
<div class="min-h-16 w-full p-4 text-xl font-medium">
<span class="inline-flex items-baseline">
<slot name="icon" />
<slot name="title" />
</span>
</div>
<div class="flex flex-col gap-2 p-4 pt-0">
<slot />
</div>
</div>
{/if}
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import Loader from '~icons/tabler/loader-2';
</script>
<div class="flex h-full w-full flex-col items-center justify-center p-6">
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
<p class="text-xl">Loading...</p>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script>
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications';
import error from '~icons/tabler/circle-x';
import success from '~icons/tabler/circle-check';
import warning from '~icons/tabler/alert-triangle';
import info from '~icons/tabler/info-circle';
export let theme = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
info: 'alert-info'
};
export let icon = {
error: error,
success: success,
warning: warning,
info: info
};
</script>
<div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)}
<div
animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }}
>
<svelte:component this={icon[notification.type]} class="h-6 w-6 flex-shrink-0" />
<span>{notification.message}</span>
</div>
{/each}
</div>
@@ -0,0 +1,112 @@
<script lang="ts">
import { page } from '$app/stores';
import { openModal, closeAllModals } from 'svelte-modals';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Firmware from '~icons/tabler/refresh-alert';
import Cancel from '~icons/tabler/x';
import CloudDown from '~icons/tabler/cloud-download';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { compareVersions } from 'compare-versions';
import { onMount } from 'svelte';
export let update = false;
let firmwareVersion: string;
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();
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('/rest/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);
}
}
onMount(() => {
if ($page.data.features.download_firmware && (!$page.data.features.security || $user.admin)) {
getGithubAPI();
const interval = setInterval(
async () => {
getGithubAPI();
},
60 * 60 * 1000
); // once per hour
}
});
function confirmGithubUpdate(url: string) {
openModal(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);
openModal(GithubUpdateDialog, {
onConfirm: () => closeAllModals()
});
}
});
}
</script>
{#if update}
<button
class="btn btn-square btn-ghost h-9 w-9"
on:click={() => confirmGithubUpdate(firmwareDownloadLink)}
>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>{firmwareVersion}</span
>
<Firmware class="h-7 w-7" />
</button>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { BufferGeometry, CanvasTexture, CircleGeometry, CubicBezierCurve3, Line, LineBasicMaterial, Mesh, MeshBasicMaterial, Vector3, type NormalBufferAttributes } from 'three';
import { BufferGeometry, Line, LineBasicMaterial, Vector3, type NormalBufferAttributes } from 'three';
import socketService from '$lib/services/socket-service';
import uzip from 'uzip';
import { model } from '$lib/stores';
@@ -11,29 +11,30 @@
import { lerp, degToRad } from 'three/src/math/MathUtils';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
let sceneManager = new SceneBuilder();
let canvas: HTMLCanvasElement, streamCanvas: HTMLCanvasElement, stream: HTMLImageElement;
let context: CanvasRenderingContext2D, texture: CanvasTexture;
export let sky = true
export let orbit = false
export let panel = true
export let debug = false
let modelAngles: number[] | Int16Array = new Array(12).fill(0);
let modelTargetAngles: number[] | Int16Array = new Array(12).fill(0);
let sceneManager = new SceneBuilder();
let canvas: HTMLCanvasElement
let modelAngles: number[] | Int16Array = [0, 45, 45, 0, 45, 45, 0, 45, 45, 0, 45, 45]// new Array(12).fill(0);
let modelTargetAngles: number[] | Int16Array = [0, 45, 45, 0, 45, 45, 0, 45, 45, 0, 45, 45] //new Array(12).fill(0);
let feet_trace = new Array(4).fill([]);
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
const videoStream = `//${location}/api/stream`;
let showStream = false;
let settings = {
'Trace feet':true,
'Trace points': 30
'Trace feet':debug,
'Trace points': 30,
'Fix camera on robot': true
}
onMount(async () => {
await cacheModelFiles()
await createScene();
if (!isEmbeddedApp) createPanel();
if (!isEmbeddedApp && panel) createPanel();
});
onDestroy(() => {
@@ -74,26 +75,24 @@
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas: canvas, alpha: true })
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(8, 30)
// .addSky()
.setEnvironment("Asphalt")
.addOrbitControls(8, 30, orbit)
.addGroundPlane()
.addGridHelper({ grid:{size: 250, divisions: 125 }})
// .addGridHelper({ grid:{size: 250, divisions: 125 }})
.addAmbientLight({ color: 0xffffff, intensity: 0.7 })
.addDirectionalLight({ x: 10, y: 100, z: 10, color: 0xffffff, intensity: 1 })
.addArrowHelper({ origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: -2, z: 0 } })
// .addArrowHelper({ origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: -2, z: 0 } })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addDragControl(updateAngles)
.handleResize()
.fillParent()
.addRenderCb(render)
.startRenderLoop();
if (sky) sceneManager.addSky()
addVideoStream();
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry();
const material = new LineBasicMaterial({ color: footColor() });
const line = new Line(geometry, material);
@@ -102,24 +101,6 @@
}
};
const addVideoStream = () => {
context = streamCanvas.getContext('2d')!;
texture = new CanvasTexture(stream);
const liveStream = new Mesh(
new CircleGeometry(35, 32),
new MeshBasicMaterial({ map: texture })
);
liveStream.position.z = -50;
liveStream.visible = showStream;
sceneManager.scene.add(liveStream);
};
const handleVideoStream = () => {
if (!showStream) return;
context.drawImage(stream, 0, 0);
texture.needsUpdate = true;
};
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
@@ -143,12 +124,14 @@
renderTraceLines(toes)
if (settings['Fix camera on robot']) {
sceneManager.controls.target = robot.position.clone()
}
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y));
robot.rotation.z = lerp(robot.rotation.z, degToRad($mpu.heading + 90), 0.1);
modelTargetAngles = $servoAngles;
handleVideoStream();
for (let i = 0; i < $jointNames.length; i++) {
modelAngles[i] = lerp(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
@@ -158,18 +141,10 @@
robot.joints[$jointNames[i]].setJointValue(degToRad(modelAngles[i]));
}
};
</script>
<svelte:window on:resize={sceneManager.handleResize} />
<svelte:window on:resize={sceneManager.fillParent} />
{#if showStream}
<img
bind:this={stream}
src={videoStream}
class="hidden"
alt="Live stream is down"
crossorigin="anonymous"
/>
{/if}
<canvas bind:this={streamCanvas} class="hidden"></canvas>
<canvas bind:this={canvas} class="absolute"></canvas>
<canvas bind:this={canvas}></canvas>
+52
View File
@@ -0,0 +1,52 @@
import { writable, derived } from 'svelte/store';
type State = {
id: string;
type: string;
message: string;
timeout: number;
};
function createNotificationStore() {
const state: State[] = [];
const _notifications = writable(state);
function send(message: string, type = 'info', timeout: number) {
_notifications.update((state) => {
return [...state, { id: id(), type, message, timeout }];
});
}
let timers = [];
const notifications = derived(_notifications, ($_notifications, set) => {
set($_notifications);
if ($_notifications.length > 0) {
const timer = setTimeout(() => {
_notifications.update((state) => {
state.shift();
return state;
});
}, $_notifications[0].timeout);
return () => {
clearTimeout(timer);
};
}
});
const { subscribe } = notifications;
return {
subscribe,
send,
error: (msg: string, timeout: number) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number) => send(msg, 'info', timeout),
success: (msg: string, timeout: number) => send(msg, 'success', timeout)
};
}
function id() {
return '_' + Math.random().toString(36).substr(2, 9);
}
export const notifications = createNotificationStore();
@@ -0,0 +1,37 @@
<script>
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications';
import error from '~icons/tabler/circle-x';
import success from '~icons/tabler/circle-check';
import warning from '~icons/tabler/alert-triangle';
import info from '~icons/tabler/info-circle';
export let theme = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
info: 'alert-info'
};
export let icon = {
error: error,
success: success,
warning: warning,
info: info
};
</script>
<div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)}
<div
animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }}
>
<svelte:component this={icon[notification.type]} class="h-6 w-6 flex-shrink-0" />
<span>{notification.message}</span>
</div>
{/each}
</div>
@@ -0,0 +1,52 @@
import { writable, derived, type Writable } from 'svelte/store';
type NotificationType = 'info' | 'error' | 'warning' | 'success';
type State = {
id: string;
type: NotificationType;
message: string;
timeout: number;
};
function createNotificationStore() {
const state: State[] = [];
const _notifications = writable(state);
function send(message: string, type: NotificationType = 'info', timeout: number) {
_notifications.update((state) => {
return [...state, { id: id(), type, message, timeout }];
});
}
const notifications = derived(_notifications, ($_notifications, set) => {
set($_notifications);
if ($_notifications.length > 0) {
const timer = setTimeout(() => {
_notifications.update((state) => {
state.shift();
return state;
});
}, $_notifications[0].timeout);
return () => {
clearTimeout(timer);
};
}
}) as Writable<State[]>;
const { subscribe } = notifications;
return {
subscribe,
send,
error: (msg: string, timeout: number) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number) => send(msg, 'info', timeout),
success: (msg: string, timeout: number) => send(msg, 'success', timeout)
};
}
function id() {
return '_' + Math.random().toString(36).substr(2, 9);
}
export const notifications = createNotificationStore();
+18 -8
View File
@@ -11,8 +11,6 @@ import {
GridHelper,
ArrowHelper,
Vector3,
LoaderUtils,
Object3D,
FogExp2,
CanvasTexture,
type ColorRepresentation,
@@ -92,7 +90,7 @@ export default class SceneBuilder {
this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.85;
document.body.appendChild(this.renderer.domElement);
if (!parameters.canvas) document.body.appendChild(this.renderer.domElement);
return this;
};
@@ -126,7 +124,7 @@ export default class SceneBuilder {
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera();
this.camera.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0);
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0);
this.scene.add(this.camera);
return this;
};
@@ -141,10 +139,11 @@ export default class SceneBuilder {
return this;
};
public addOrbitControls = (minDistance: number, maxDistance: number) => {
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.minDistance = minDistance;
this.controls.maxDistance = maxDistance;
this.controls.autoRotate = autoRotate;
this.controls.update();
return this;
};
@@ -182,10 +181,20 @@ export default class SceneBuilder {
return this;
};
public handleResize = () => {
this.renderer.setSize(window.innerWidth, window.innerHeight);
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement;
if (parentElement) {
const width = parentElement.clientWidth;
const height = parentElement.clientHeight;
this.handleResize(width, height);
}
return this;
};
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
return this;
};
@@ -198,6 +207,7 @@ export default class SceneBuilder {
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera);
this.controls.update();
this.handleRobotShadow();
if (this.callback) this.callback();
if (!this.liveStreamTexture) return;
+36
View File
@@ -0,0 +1,36 @@
import { writable } from 'svelte/store';
let analytics_data = {
uptime: <number[]>[],
free_heap: <number[]>[],
total_heap: <number[]>[],
min_free_heap: <number[]>[],
max_alloc_heap: <number[]>[],
fs_used: <number[]>[],
fs_total: <number[]>[],
core_temp: <number[]>[]
};
function createAnalytics() {
const { subscribe, set, update } = writable(analytics_data);
return {
subscribe,
addData: (data: string) => {
const content = JSON.parse(data);
update((analytics_data) => ({
...analytics_data,
uptime: [...analytics_data.uptime, content.uptime],
free_heap: [...analytics_data.free_heap, content.free_heap / 1000],
total_heap: [...analytics_data.total_heap, content.total_heap / 1000],
min_free_heap: [...analytics_data.min_free_heap, content.min_free_heap / 1000],
max_alloc_heap: [...analytics_data.max_alloc_heap, content.max_alloc_heap / 1000],
fs_used: [...analytics_data.fs_used, content.fs_used / 1000],
fs_total: [...analytics_data.fs_total, content.fs_total / 1000],
core_temp: [...analytics_data.core_temp, content.core_temp]
}));
}
};
}
export const analytics = createAnalytics();
+3 -1
View File
@@ -2,7 +2,9 @@ import { writable, type Writable } from 'svelte/store';
import { type angles } from '$lib/models';
export const isConnected = writable(false);
export const servoAngles: Writable<angles> = writable(new Int16Array(12).fill(0));
export const servoAngles: Writable<angles> = writable([
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
]);
export const logs = writable([] as string[]);
export const battery = writable({});
export const mpu = writable({ heading: 0 });
+52
View File
@@ -0,0 +1,52 @@
import { writable } from 'svelte/store';
let telemetry_data = {
serverAvailable: true,
rssi: {
rssi: 0,
disconnected: true
},
battery: {
soc: 100,
charging: false
},
download_ota: {
status: 'none',
progress: 0,
error: ''
}
};
function createTelemetry() {
const { subscribe, set, update } = writable(telemetry_data);
return {
subscribe,
setRSSI: (data: string) => {
if (!isNaN(Number(data))) {
update((telemerty_data) => ({
...telemerty_data,
rssi: { rssi: Number(data), disconnected: false }
}));
} else {
update((telemerty_data) => ({ ...telemerty_data, rssi: { rssi: 0, disconnected: true } }));
}
},
setBattery: (data: string) => {
const content = JSON.parse(data);
update((telemerty_data) => ({
...telemerty_data,
battery: { soc: content.soc, charging: content.charging }
}));
},
setDownloadOTA: (data: string) => {
const content = JSON.parse(data);
update((telemerty_data) => ({
...telemerty_data,
download_ota: { status: content.status, progress: content.progress, error: content.error }
}));
}
};
}
export const telemetry = createTelemetry();
+55
View File
@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { jwtDecode } from 'jwt-decode';
export type userProfile = {
username: string;
admin: boolean;
bearer_token: string;
};
type decodedJWT = {
username: string;
admin: boolean;
};
let empty = {
username: '',
admin: false,
bearer_token: ''
};
function createStore() {
const { subscribe, set } = writable(empty);
// retrieve store from sessionStorage / localStorage if available
const userdata = localStorage.getItem('user');
if (userdata) {
set(JSON.parse(userdata));
}
return {
subscribe,
init: (access_token: string) => {
const decoded: decodedJWT = jwtDecode(access_token);
const userdata = {
bearer_token: access_token,
username: decoded.username,
admin: decoded.admin
};
set(userdata);
// persist store in sessionStorage / localStorage
localStorage.setItem('user', JSON.stringify(userdata));
},
invalidate: () => {
console.log('Log out user');
set(empty);
// remove localStorage "user"
localStorage.removeItem('user');
// redirect to login page
goto('/');
}
};
}
export const user = createStore();
+203 -24
View File
@@ -1,34 +1,213 @@
<script lang="ts">
import type { LayoutData } from './$types';
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user';
import { telemetry } from '$lib/stores/telemetry';
import { analytics } from '$lib/stores/analytics';
import type { userProfile } from '$lib/stores/user';
import { page } from '$app/stores';
import TopBar from '$lib/components/TopBar.svelte';
import Menu from './menu.svelte';
import '../app.css';
import { Modals, closeModal } 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 './menu.svelte';
import Statusbar from './statusbar.svelte';
import Login from './login.svelte';
export let data: LayoutData;
onMount(() => {
if ($user.bearer_token !== '') {
validateUser($user);
}
menuOpen = false;
connectToEventSource();
});
onDestroy(() => {
NotificationSource?.close();
});
onDestroy(() => {
NotificationSource.close();
});
async function validateUser(userdata: userProfile) {
try {
const response = await fetch('/rest/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);
}
return;
}
let menuOpen = false;
let NotificationSource: EventSource;
let reconnectIntervalId: number = 0;
let connectionLost = false;
let unresponsiveTimeout: number;
function connectToEventSource() {
NotificationSource = new EventSource('/events');
console.log('Attempting SSE connection.');
NotificationSource.addEventListener('open', () => {
clearInterval(reconnectIntervalId);
reconnectIntervalId = 0;
connectionLost = false;
console.log('SSE connection established');
notifications.success('Connection to device established', 5000);
telemetry.setRSSI('found'); // Update store and flag as server being available again
});
NotificationSource.addEventListener(
'rssi',
(event) => {
telemetry.setRSSI(event.data);
// Reset a timer to detect unresponsiveness
clearTimeout(unresponsiveTimeout);
unresponsiveTimeout = setTimeout(() => {
console.log('Server is unresponsive');
reconnectEventSource();
}, 2000) as unknown as number; // Detect unresponsiveness after 2 seconds
},
false
);
NotificationSource.addEventListener(
'error',
(event) => {
reconnectEventSource();
},
false
);
NotificationSource.addEventListener(
'close',
(event) => {
reconnectEventSource();
},
false
);
NotificationSource.addEventListener(
'infoToast',
(event) => {
notifications.info(event.data, 5000);
},
false
);
NotificationSource.addEventListener(
'successToast',
(event) => {
notifications.success(event.data, 5000);
},
false
);
NotificationSource.addEventListener(
'warningToast',
(event) => {
notifications.warning(event.data, 5000);
},
false
);
NotificationSource.addEventListener(
'errorToast',
(event) => {
notifications.error(event.data, 5000);
},
false
);
NotificationSource.addEventListener(
'battery',
(event) => {
telemetry.setBattery(event.data);
},
false
);
NotificationSource.addEventListener(
'download_ota',
(event) => {
telemetry.setDownloadOTA(event.data);
},
false
);
NotificationSource.addEventListener(
'analytics',
(event) => {
analytics.addData(event.data);
},
false
);
}
function reconnectEventSource() {
if (connectionLost === false) {
NotificationSource.close;
notifications.error('Connection to device lost', 5000);
if (reconnectIntervalId === 0) {
reconnectIntervalId = setInterval(connectToEventSource, 2000) as unknown as number;
console.log('SSE reconnect Timer ID: ' + reconnectIntervalId);
}
}
connectionLost = true;
}
let menuOpen = false;
</script>
<TopBar />
<svelte:head>
<title>{$page.data.title}</title>
</svelte:head>
<div class="drawer lg:drawer-open">
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
<!-- <Statusbar /> -->
{#if $page.data.features.security && $user.bearer_token === ''}
<Login />
{:else}
<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 />
<!-- Main page content here -->
<slot />
</div>
<!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay" />
<Menu
on:menuClicked={() => {
menuOpen = false;
}}
/>
</div>
</div>
<!-- Main page content here -->
<slot />
</div>
<!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay" />
<Menu
on:menuClicked={() => {
menuOpen = false;
}}
/>
</div>
</div>
{/if}
<Modals>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
slot="backdrop"
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur"
transition:fade
on:click={closeModal}
/>
</Modals>
<Toast />
+10 -12
View File
@@ -1,24 +1,20 @@
import { jointNames, model } from '$lib/stores';
import { loadModelAsync } from '$lib/utilities/model-utilities';
import type { Result } from '$lib/utilities/result';
export const prerender = true;
export const ssr = false;
const registerFetchIntercept = async () => {
if (typeof WebSocket === 'undefined') return;
const { fetch: originalFetch } = window;
const fileService = (await import('$lib/services/file-service')).default;
window.fetch = async (...args) => {
const [resource, config] = args;
let file: Result<Uint8Array | undefined, string>;
file = await fileService.getFile(resource.toString());
window.fetch = async (resource, config) => {
let url = resource instanceof Request ? resource.url : resource.toString();
let file = await fileService.getFile(url);
return file.isOk() ? new Response(file.inner) : originalFetch(resource, config);
};
};
const setup = async () => {
if (typeof WebSocket === 'undefined') return;
const outControllerData = (await import('$lib/stores/model-store')).outControllerData;
const mode = (await import('$lib/stores/model-store')).mode;
const socketLocation = (await import('$lib/utilities/location-utilities')).socketLocation;
@@ -40,11 +36,13 @@ const setup = async () => {
export const load = async () => {
await setup();
// const result = await fetch('/rest/features');
const item = {}; //await result.json();
const result = await fetch('/rest/features');
const features = await result.json();
return {
features: item,
title: 'spot-micro-controller',
github: 'runeharlyk/spotmicro'
features,
title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller',
copyright: '2024 Rune Harlyk'
};
};
+24 -2
View File
@@ -1,2 +1,24 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<script lang="ts">
import type { PageData } from './$types';
import { notifications } from '$lib/components/toasts/notifications';
import Visualization from '$lib/components/Visualization.svelte';
export let data: PageData;
</script>
<div class="hero bg-base-100 h-screen">
<div class="card md:card-side bg-base-200 shadow-2xl">
<div class="w-64 h-64">
<Visualization sky={false} orbit={true} panel={false}/>
</div>
<div class="card-body w-80">
<h2 class="card-title text-center text-2xl">Welcome to {data.app_name}</h2>
<p class="py-6 text-center"></p>
<a
class="btn btn-primary"
href="/controller"
on:click={() => notifications.success('You did it!', 1000)}>Begin</a
>
</div>
</div>
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
import { goto } from '$app/navigation';
export const load = (async () => {
goto('/');
return;
}) satisfies PageLoad;
@@ -0,0 +1,15 @@
<script lang="ts">
import type { PageData } from '../$types';
import MQTT from './MQTT.svelte';
import MqttConfig from './MQTTConfig.svelte';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<MQTT />
<MqttConfig />
</div>
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: "MQTT"
};
}) satisfies PageLoad;
@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import InputPassword from '$lib/components/InputPassword.svelte';
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 Collapsible from '$lib/components/Collapsible.svelte';
import MQTT from '~icons/tabler/topology-star-3';
import Client from '~icons/tabler/robot';
type MQTTStatus = {
enabled: boolean;
connected: boolean;
client_id: string;
last_error: string;
};
type MQTTSettings = {
enabled: boolean;
uri: string;
username: string;
password: string;
client_id: string;
keep_alive: number;
clean_session: boolean;
};
let mqttSettings: MQTTSettings;
let mqttStatus: MQTTStatus;
let formField: any;
async function getMQTTStatus() {
try {
const response = await fetch('/rest/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);
}
return mqttStatus;
}
async function getMQTTSettings() {
try {
const response = await fetch('/rest/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);
}
return mqttSettings;
}
const interval = setInterval(async () => {
getMQTTStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getMQTTSettings();
}
});
let formErrors = {
host: false,
port: false,
keep_alive: false,
topic_length: false
};
async function postMQTTSettings(data: MQTTSettings) {
try {
const response = await fetch('/rest/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('Security settings updated.', 3000);
mqttSettings = await response.json();
} else {
notifications.error('User not authorized.', 3000);
}
} catch (error) {
console.error('Error:', error);
}
return;
}
function handleSubmitMQTT() {
let valid = true;
// Validate Server URI
const regexExpURL =
/(([-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4})|(\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b))(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i;
if (!regexExpURL.test(mqttSettings.uri)) {
valid = false;
formErrors.host = true;
} else {
formErrors.host = false;
}
// Validate if port is a number and within the right range
let keepalive = Number(mqttSettings.keep_alive);
if (1 <= keepalive && keepalive <= 600) {
formErrors.keep_alive = false;
} else {
formErrors.keep_alive = true;
valid = false;
}
// Submit JSON to REST API
if (valid) {
postMQTTSettings(mqttSettings);
//alert('Form Valid');
}
}
</script>
<SettingsCard collapsible={false}>
<MQTT slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">MQTT</span>
<div class="w-full overflow-x-auto">
{#await getMQTTStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {mqttStatus.connected === true
? 'bg-success'
: 'bg-error'}"
>
<MQTT
class="h-auto w-full scale-75 {mqttStatus.connected === true
? 'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{#if mqttStatus.connected}
Connected
{:else if !mqttStatus.enabled}
MQTT Disabled
{:else}
{mqttStatus.last_error}
{/if}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Client class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Client ID</div>
<div class="text-sm opacity-75">
{mqttStatus.client_id}
</div>
</div>
</div>
</div>
{/await}
</div>
{#if !$page.data.features.security || $user.admin}
<Collapsible open={false} class="shadow-lg" on:closed={getMQTTSettings}>
<span slot="title">Change MQTT Settings</span>
<form on:submit|preventDefault={handleSubmitMQTT} novalidate bind:this={formField}>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<!-- Enable -->
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<input
type="checkbox"
bind:checked={mqttSettings.enabled}
class="checkbox checkbox-primary"
/>
<span>Enable MQTT</span>
</label>
<div class="hidden sm:block" />
<!-- URI -->
<div class="sm:col-span-2">
<label class="label" for="host">
<span class="label-text text-md">URI</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.host
? 'border-error border-2'
: ''}"
bind:value={mqttSettings.uri}
id="host"
min="3"
max="64"
required
/>
<label class="label" for="host">
<span class="label-text-alt text-error {formErrors.host ? '' : 'hidden'}"
>Must be a valid URI</span
>
</label>
</div>
<!-- Username -->
<div>
<label class="label" for="user">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
class="input input-bordered w-full"
bind:value={mqttSettings.username}
id="user"
/>
</div>
<!-- Password -->
<div>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<InputPassword bind:value={mqttSettings.password} id="pwd" />
</div>
<!-- Client ID -->
<div>
<label class="label" for="clientid">
<span class="label-text text-md">Client ID</span>
</label>
<input
type="text"
class="input input-bordered w-full"
bind:value={mqttSettings.client_id}
id="clientid"
/>
</div>
<!-- Keep Alive -->
<div>
<label class="label" for="keepalive">
<span class="label-text text-md">Keep Alive</span>
</label>
<label for="keepalive" class="input-group">
<input
type="number"
min="1"
max="600"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.keep_alive
? 'border-error border-2'
: ''}"
bind:value={mqttSettings.keep_alive}
id="keepalive"
required
/>
<span>Seconds</span>
</label>
<label for="keepalive" class="label"
><span class="label-text-alt text-error {formErrors.keep_alive ? '' : 'hidden'}"
>Must be between 1 and 600 seconds</span
></label
>
</div>
<!-- Clean Session -->
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<input
type="checkbox"
bind:checked={mqttSettings.clean_session}
class="checkbox checkbox-primary"
/>
<span class="">Clean Session?</span>
</label>
</div>
<div class="divider mb-2 mt-0" />
<div class="mx-4 flex flex-wrap justify-end gap-2">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</Collapsible>
{/if}
</SettingsCard>
@@ -0,0 +1,192 @@
<script lang="ts">
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';
type BrokerSettings = {
mqtt_path: string;
name: string;
unique_id: string;
};
let brokerSettings: BrokerSettings;
let formField: any;
async function getBrokerSettings() {
try {
const response = await fetch('/rest/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;
}
let formErrors = {
uid: false,
path: false,
name: false
};
async function postBrokerSettings() {
try {
const response = await fetch('/rest/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;
}
function handleSubmitBroker() {
let valid = true;
// Validate unique ID
if (brokerSettings.unique_id.length < 3 || brokerSettings.unique_id.length > 32) {
valid = false;
formErrors.uid = true;
} else {
formErrors.uid = false;
}
// Validate name
if (brokerSettings.name.length < 3 || brokerSettings.name.length > 32) {
valid = false;
formErrors.name = true;
} else {
formErrors.name = false;
}
// Validate MQTT Path
if (brokerSettings.mqtt_path.length > 64) {
valid = false;
formErrors.path = true;
} else {
formErrors.path = false;
}
// Submit JSON to REST API
if (valid) {
postBrokerSettings();
//alert('Form Valid');
}
}
</script>
<SettingsCard collapsible={true} open={false}>
<MQTT slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">MQTT Broker Settings</span>
<div class="w-full overflow-x-auto">
{#await getBrokerSettings()}
<Spinner />
{:then nothing}
<form
on:submit|preventDefault={handleSubmitBroker}
novalidate
bind:this={formField}
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="alert alert-info my-2 shadow-lg">
<Info class="h-6 w-6 flex-shrink-0 stroke-current" />
<span
>The LED is controllable via MQTT with the demo project designed to work with Home
Assistant's auto discovery feature.</span
>
</div>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4">
<div>
<label class="label" for="uid">
<span class="label-text text-md">Unique ID</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.uid
? 'border-error border-2'
: ''}"
bind:value={brokerSettings.unique_id}
id="uid"
min="3"
max="32"
required
/>
<label class="label" for="uid">
<span class="label-text-alt text-error {formErrors.uid ? '' : 'hidden'}"
>Unique ID must be between 3 and 32 characters long</span
>
</label>
</div>
<div>
<label class="label" for="name">
<span class="label-text text-md">Name</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.name
? 'border-error border-2'
: ''}"
bind:value={brokerSettings.name}
id="name"
min="3"
max="32"
required
/>
<label class="label" for="name">
<span class="label-text-alt text-error {formErrors.name ? '' : 'hidden'}"
>Name must be between 3 and 32 characters long</span
>
</label>
</div>
<div>
<label class="label" for="path">
<span class="label-text text-md">MQTT Path</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.path
? 'border-error border-2'
: ''}"
bind:value={brokerSettings.mqtt_path}
id="path"
min="0"
max="64"
required
/>
<label class="label" for="path">
<span class="label-text-alt text-error {formErrors.path ? '' : 'hidden'}"
>MQTT path is limited to 64 characters</span
>
</label>
</div>
</div>
<div class="divider mb-2 mt-0" />
<div class="mx-4 flex flex-wrap justify-end gap-2">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
{/await}
</div>
</SettingsCard>
@@ -0,0 +1,10 @@
<script lang="ts">
import type { PageData } from '../$types';
import NTP from './NTP.svelte';
export let data: PageData;
</script>
<div class="mx-0 my-1 flex flex-col space-y-4 sm:mx-8 sm:my-8">
<NTP />
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'NTP'
};
}) satisfies PageLoad;
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import Collapsible from '$lib/components/Collapsible.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications';
import type { TimeZones } from './timezones';
import { TIME_ZONES } from './timezones';
import NTP from '~icons/tabler/clock-check';
import Server from '~icons/tabler/server';
import Clock from '~icons/tabler/clock';
import UTC from '~icons/tabler/clock-pin';
import Stopwatch from '~icons/tabler/24-hours';
type NTPStatus = {
status: number;
utc_time: string;
local_time: string;
server: string;
uptime: number;
};
type NTPSettings = {
enabled: boolean;
server: string;
tz_label: string;
tz_format: string;
};
let ntpSettings: NTPSettings;
let ntpStatus: NTPStatus;
async function getNTPStatus() {
try {
const response = await fetch('/rest/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;
}
async function getNTPSettings() {
try {
const response = await fetch('/rest/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 interval = setInterval(async () => {
getNTPStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getNTPSettings();
}
});
let formField: any;
let formErrors = {
server: false
};
async function postNTPSettings(data: NTPSettings) {
try {
const response = await fetch('/rest/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);
}
}
function handleSubmitNTP() {
let valid = true;
// Validate Server
// RegEx for IPv4
const regexExpIPv4 =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
const regexExpURL =
/[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i;
if (!regexExpURL.test(ntpSettings.server) && !regexExpIPv4.test(ntpSettings.server)) {
valid = false;
formErrors.server = true;
} else {
formErrors.server = false;
}
ntpSettings.tz_format = TIME_ZONES[ntpSettings.tz_label];
// Submit JSON to REST API
if (valid) {
postNTPSettings(ntpSettings);
//alert('Form Valid');
}
}
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;
}
</script>
<SettingsCard collapsible={false}>
<Clock slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Network Time</span>
<div class="w-full overflow-x-auto">
{#await getNTPStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {ntpStatus.status === 1
? 'bg-success'
: 'bg-error'}"
>
<NTP
class="h-auto w-full scale-75 {ntpStatus.status === 1
? 'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{ntpStatus.status === 1 ? 'Active' : 'Inactive'}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Server class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">NTP Server</div>
<div class="text-sm opacity-75">
{ntpStatus.server}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Clock class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Local Time</div>
<div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeStyle: 'long'
}).format(new Date(ntpStatus.local_time))}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<UTC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">UTC Time</div>
<div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeStyle: 'long',
timeZone: 'UTC'
}).format(new Date(ntpStatus.utc_time))}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Uptime</div>
<div class="text-sm opacity-75">
{convertSeconds(ntpStatus.uptime)}
</div>
</div>
</div>
</div>
{/await}
</div>
{#if !$page.data.features.security || $user.admin}
<Collapsible open={false} class="shadow-lg" on:closed={getNTPSettings}>
<span slot="title">Change NTP Settings</span>
<form
class="form-control w-full"
on:submit|preventDefault={handleSubmitNTP}
novalidate
bind:this={formField}
>
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={ntpSettings.enabled}
class="checkbox checkbox-primary"
/>
<span class="">Enable NTP</span>
</label>
<label class="label" for="server">
<span class="label-text text-md">Server</span>
</label>
<input
type="text"
min="3"
max="64"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.server
? 'border-error border-2'
: ''}"
bind:value={ntpSettings.server}
id="server"
required
/>
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.server ? '' : 'hidden'}"
>Must be a valid IPv4 address or URL</span
>
</label>
<label class="label" for="tz">
<span class="label-text text-md">Pick Time Zone</span>
</label>
<select class="select select-bordered" bind:value={ntpSettings.tz_label} id="tz">
{#each Object.entries(TIME_ZONES) as [tz_label, tz_format]}
<option value={tz_label}>{tz_label}</option>
{/each}
</select>
<div class="mt-6 place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</Collapsible>
{/if}
</SettingsCard>
@@ -0,0 +1,466 @@
export type TimeZones = {
[name: string]: string
};
export const TIME_ZONES: TimeZones = {
"Africa/Abidjan": "GMT0",
"Africa/Accra": "GMT0",
"Africa/Addis_Ababa": "EAT-3",
"Africa/Algiers": "CET-1",
"Africa/Asmara": "EAT-3",
"Africa/Bamako": "GMT0",
"Africa/Bangui": "WAT-1",
"Africa/Banjul": "GMT0",
"Africa/Bissau": "GMT0",
"Africa/Blantyre": "CAT-2",
"Africa/Brazzaville": "WAT-1",
"Africa/Bujumbura": "CAT-2",
"Africa/Cairo": "EET-2",
"Africa/Casablanca": "UNK-1",
"Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Africa/Conakry": "GMT0",
"Africa/Dakar": "GMT0",
"Africa/Dar_es_Salaam": "EAT-3",
"Africa/Djibouti": "EAT-3",
"Africa/Douala": "WAT-1",
"Africa/El_Aaiun": "UNK-1",
"Africa/Freetown": "GMT0",
"Africa/Gaborone": "CAT-2",
"Africa/Harare": "CAT-2",
"Africa/Johannesburg": "SAST-2",
"Africa/Juba": "EAT-3",
"Africa/Kampala": "EAT-3",
"Africa/Khartoum": "CAT-2",
"Africa/Kigali": "CAT-2",
"Africa/Kinshasa": "WAT-1",
"Africa/Lagos": "WAT-1",
"Africa/Libreville": "WAT-1",
"Africa/Lome": "GMT0",
"Africa/Luanda": "WAT-1",
"Africa/Lubumbashi": "CAT-2",
"Africa/Lusaka": "CAT-2",
"Africa/Malabo": "WAT-1",
"Africa/Maputo": "CAT-2",
"Africa/Maseru": "SAST-2",
"Africa/Mbabane": "SAST-2",
"Africa/Mogadishu": "EAT-3",
"Africa/Monrovia": "GMT0",
"Africa/Nairobi": "EAT-3",
"Africa/Ndjamena": "WAT-1",
"Africa/Niamey": "WAT-1",
"Africa/Nouakchott": "GMT0",
"Africa/Ouagadougou": "GMT0",
"Africa/Porto-Novo": "WAT-1",
"Africa/Sao_Tome": "GMT0",
"Africa/Tripoli": "EET-2",
"Africa/Tunis": "CET-1",
"Africa/Windhoek": "CAT-2",
"America/Adak": "HST10HDT,M3.2.0,M11.1.0",
"America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Anguilla": "AST4",
"America/Antigua": "AST4",
"America/Araguaina": "UNK3",
"America/Argentina/Buenos_Aires": "UNK3",
"America/Argentina/Catamarca": "UNK3",
"America/Argentina/Cordoba": "UNK3",
"America/Argentina/Jujuy": "UNK3",
"America/Argentina/La_Rioja": "UNK3",
"America/Argentina/Mendoza": "UNK3",
"America/Argentina/Rio_Gallegos": "UNK3",
"America/Argentina/Salta": "UNK3",
"America/Argentina/San_Juan": "UNK3",
"America/Argentina/San_Luis": "UNK3",
"America/Argentina/Tucuman": "UNK3",
"America/Argentina/Ushuaia": "UNK3",
"America/Aruba": "AST4",
"America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0",
"America/Atikokan": "EST5",
"America/Bahia": "UNK3",
"America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0",
"America/Barbados": "AST4",
"America/Belem": "UNK3",
"America/Belize": "CST6",
"America/Blanc-Sablon": "AST4",
"America/Boa_Vista": "UNK4",
"America/Bogota": "UNK5",
"America/Boise": "MST7MDT,M3.2.0,M11.1.0",
"America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0",
"America/Campo_Grande": "UNK4",
"America/Cancun": "EST5",
"America/Caracas": "UNK4",
"America/Cayenne": "UNK3",
"America/Cayman": "EST5",
"America/Chicago": "CST6CDT,M3.2.0,M11.1.0",
"America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0",
"America/Costa_Rica": "CST6",
"America/Creston": "MST7",
"America/Cuiaba": "UNK4",
"America/Curacao": "AST4",
"America/Danmarkshavn": "GMT0",
"America/Dawson": "MST7",
"America/Dawson_Creek": "MST7",
"America/Denver": "MST7MDT,M3.2.0,M11.1.0",
"America/Detroit": "EST5EDT,M3.2.0,M11.1.0",
"America/Dominica": "AST4",
"America/Edmonton": "MST7MDT,M3.2.0,M11.1.0",
"America/Eirunepe": "UNK5",
"America/El_Salvador": "CST6",
"America/Fort_Nelson": "MST7",
"America/Fortaleza": "UNK3",
"America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1",
"America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0",
"America/Grenada": "AST4",
"America/Guadeloupe": "AST4",
"America/Guatemala": "CST6",
"America/Guayaquil": "UNK5",
"America/Guyana": "UNK4",
"America/Halifax": "AST4ADT,M3.2.0,M11.1.0",
"America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1",
"America/Hermosillo": "MST7",
"America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Knox": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Marengo": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Petersburg": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Tell_City": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Vevay": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Vincennes": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Winamac": "EST5EDT,M3.2.0,M11.1.0",
"America/Inuvik": "MST7MDT,M3.2.0,M11.1.0",
"America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0",
"America/Jamaica": "EST5",
"America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Kentucky/Louisville": "EST5EDT,M3.2.0,M11.1.0",
"America/Kentucky/Monticello": "EST5EDT,M3.2.0,M11.1.0",
"America/Kralendijk": "AST4",
"America/La_Paz": "UNK4",
"America/Lima": "UNK5",
"America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0",
"America/Lower_Princes": "AST4",
"America/Maceio": "UNK3",
"America/Managua": "CST6",
"America/Manaus": "UNK4",
"America/Marigot": "AST4",
"America/Martinique": "AST4",
"America/Matamoros": "CST6CDT,M3.2.0,M11.1.0",
"America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0",
"America/Menominee": "CST6CDT,M3.2.0,M11.1.0",
"America/Merida": "CST6CDT,M4.1.0,M10.5.0",
"America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0",
"America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0",
"America/Moncton": "AST4ADT,M3.2.0,M11.1.0",
"America/Monterrey": "CST6CDT,M4.1.0,M10.5.0",
"America/Montevideo": "UNK3",
"America/Montreal": "EST5EDT,M3.2.0,M11.1.0",
"America/Montserrat": "AST4",
"America/Nassau": "EST5EDT,M3.2.0,M11.1.0",
"America/New_York": "EST5EDT,M3.2.0,M11.1.0",
"America/Nipigon": "EST5EDT,M3.2.0,M11.1.0",
"America/Nome": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Noronha": "UNK2",
"America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/Center": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/New_Salem": "CST6CDT,M3.2.0,M11.1.0",
"America/Ojinaga": "MST7MDT,M3.2.0,M11.1.0",
"America/Panama": "EST5",
"America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0",
"America/Paramaribo": "UNK3",
"America/Phoenix": "MST7",
"America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0",
"America/Port_of_Spain": "AST4",
"America/Porto_Velho": "UNK4",
"America/Puerto_Rico": "AST4",
"America/Punta_Arenas": "UNK3",
"America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0",
"America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0",
"America/Recife": "UNK3",
"America/Regina": "CST6",
"America/Resolute": "CST6CDT,M3.2.0,M11.1.0",
"America/Rio_Branco": "UNK5",
"America/Santarem": "UNK3",
"America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24",
"America/Santo_Domingo": "AST4",
"America/Sao_Paulo": "UNK3",
"America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0",
"America/St_Barthelemy": "AST4",
"America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0",
"America/St_Kitts": "AST4",
"America/St_Lucia": "AST4",
"America/St_Thomas": "AST4",
"America/St_Vincent": "AST4",
"America/Swift_Current": "CST6",
"America/Tegucigalpa": "CST6",
"America/Thule": "AST4ADT,M3.2.0,M11.1.0",
"America/Thunder_Bay": "EST5EDT,M3.2.0,M11.1.0",
"America/Tijuana": "PST8PDT,M3.2.0,M11.1.0",
"America/Toronto": "EST5EDT,M3.2.0,M11.1.0",
"America/Tortola": "AST4",
"America/Vancouver": "PST8PDT,M3.2.0,M11.1.0",
"America/Whitehorse": "MST7",
"America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0",
"America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0",
"Antarctica/Casey": "UNK-8",
"Antarctica/Davis": "UNK-7",
"Antarctica/DumontDUrville": "UNK-10",
"Antarctica/Macquarie": "UNK-11",
"Antarctica/Mawson": "UNK-5",
"Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Antarctica/Palmer": "UNK3",
"Antarctica/Rothera": "UNK3",
"Antarctica/Syowa": "UNK-3",
"Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3",
"Antarctica/Vostok": "UNK-6",
"Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Asia/Aden": "UNK-3",
"Asia/Almaty": "UNK-6",
"Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1",
"Asia/Anadyr": "UNK-12",
"Asia/Aqtau": "UNK-5",
"Asia/Aqtobe": "UNK-5",
"Asia/Ashgabat": "UNK-5",
"Asia/Atyrau": "UNK-5",
"Asia/Baghdad": "UNK-3",
"Asia/Bahrain": "UNK-3",
"Asia/Baku": "UNK-4",
"Asia/Bangkok": "UNK-7",
"Asia/Barnaul": "UNK-7",
"Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0",
"Asia/Bishkek": "UNK-6",
"Asia/Brunei": "UNK-8",
"Asia/Chita": "UNK-9",
"Asia/Choibalsan": "UNK-8",
"Asia/Colombo": "UNK-5:30",
"Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0",
"Asia/Dhaka": "UNK-6",
"Asia/Dili": "UNK-9",
"Asia/Dubai": "UNK-4",
"Asia/Dushanbe": "UNK-5",
"Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Gaza": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Hebron": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Ho_Chi_Minh": "UNK-7",
"Asia/Hong_Kong": "HKT-8",
"Asia/Hovd": "UNK-7",
"Asia/Irkutsk": "UNK-8",
"Asia/Jakarta": "WIB-7",
"Asia/Jayapura": "WIT-9",
"Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0",
"Asia/Kabul": "UNK-4:30",
"Asia/Kamchatka": "UNK-12",
"Asia/Karachi": "PKT-5",
"Asia/Kathmandu": "UNK-5:45",
"Asia/Khandyga": "UNK-9",
"Asia/Kolkata": "IST-5:30",
"Asia/Krasnoyarsk": "UNK-7",
"Asia/Kuala_Lumpur": "UNK-8",
"Asia/Kuching": "UNK-8",
"Asia/Kuwait": "UNK-3",
"Asia/Macau": "CST-8",
"Asia/Magadan": "UNK-11",
"Asia/Makassar": "WITA-8",
"Asia/Manila": "PST-8",
"Asia/Muscat": "UNK-4",
"Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Novokuznetsk": "UNK-7",
"Asia/Novosibirsk": "UNK-7",
"Asia/Omsk": "UNK-6",
"Asia/Oral": "UNK-5",
"Asia/Phnom_Penh": "UNK-7",
"Asia/Pontianak": "WIB-7",
"Asia/Pyongyang": "KST-9",
"Asia/Qatar": "UNK-3",
"Asia/Qyzylorda": "UNK-5",
"Asia/Riyadh": "UNK-3",
"Asia/Sakhalin": "UNK-11",
"Asia/Samarkand": "UNK-5",
"Asia/Seoul": "KST-9",
"Asia/Shanghai": "CST-8",
"Asia/Singapore": "UNK-8",
"Asia/Srednekolymsk": "UNK-11",
"Asia/Taipei": "CST-8",
"Asia/Tashkent": "UNK-5",
"Asia/Tbilisi": "UNK-4",
"Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24",
"Asia/Thimphu": "UNK-6",
"Asia/Tokyo": "JST-9",
"Asia/Tomsk": "UNK-7",
"Asia/Ulaanbaatar": "UNK-8",
"Asia/Urumqi": "UNK-6",
"Asia/Ust-Nera": "UNK-10",
"Asia/Vientiane": "UNK-7",
"Asia/Vladivostok": "UNK-10",
"Asia/Yakutsk": "UNK-9",
"Asia/Yangon": "UNK-6:30",
"Asia/Yekaterinburg": "UNK-5",
"Asia/Yerevan": "UNK-4",
"Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0",
"Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Cape_Verde": "UNK1",
"Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Reykjavik": "GMT0",
"Atlantic/South_Georgia": "UNK2",
"Atlantic/St_Helena": "GMT0",
"Atlantic/Stanley": "UNK3",
"Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Brisbane": "AEST-10",
"Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Currie": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Darwin": "ACST-9:30",
"Australia/Eucla": "UNK-8:45",
"Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Lindeman": "AEST-10",
"Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0",
"Australia/Melbourne": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Perth": "AWST-8",
"Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Etc/GMT": "GMT0",
"Etc/GMT+0": "GMT0",
"Etc/GMT+1": "UNK1",
"Etc/GMT+10": "UNK10",
"Etc/GMT+11": "UNK11",
"Etc/GMT+12": "UNK12",
"Etc/GMT+2": "UNK2",
"Etc/GMT+3": "UNK3",
"Etc/GMT+4": "UNK4",
"Etc/GMT+5": "UNK5",
"Etc/GMT+6": "UNK6",
"Etc/GMT+7": "UNK7",
"Etc/GMT+8": "UNK8",
"Etc/GMT+9": "UNK9",
"Etc/GMT-0": "GMT0",
"Etc/GMT-1": "UNK-1",
"Etc/GMT-10": "UNK-10",
"Etc/GMT-11": "UNK-11",
"Etc/GMT-12": "UNK-12",
"Etc/GMT-13": "UNK-13",
"Etc/GMT-14": "UNK-14",
"Etc/GMT-2": "UNK-2",
"Etc/GMT-3": "UNK-3",
"Etc/GMT-4": "UNK-4",
"Etc/GMT-5": "UNK-5",
"Etc/GMT-6": "UNK-6",
"Etc/GMT-7": "UNK-7",
"Etc/GMT-8": "UNK-8",
"Etc/GMT-9": "UNK-9",
"Etc/GMT0": "GMT0",
"Etc/Greenwich": "GMT0",
"Etc/UCT": "UTC0",
"Etc/UTC": "UTC0",
"Etc/Universal": "UTC0",
"Etc/Zulu": "UTC0",
"Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Andorra": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Astrakhan": "UNK-4",
"Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Belgrade": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Berlin": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bratislava": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Brussels": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bucharest": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Busingen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Chisinau": "EET-2EEST,M3.5.0,M10.5.0/3",
"Europe/Copenhagen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Dublin": "IST-1GMT0,M10.5.0,M3.5.0/1",
"Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Isle_of_Man": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Istanbul": "UNK-3",
"Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Kaliningrad": "EET-2",
"Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Kirov": "UNK-3",
"Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0",
"Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/London": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Madrid": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Malta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Minsk": "UNK-3",
"Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Moscow": "MSK-3",
"Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Paris": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Podgorica": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Prague": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Riga": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Samara": "UNK-4",
"Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sarajevo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Saratov": "UNK-4",
"Europe/Simferopol": "MSK-3",
"Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sofia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Ulyanovsk": "UNK-4",
"Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Vaduz": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vatican": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vienna": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Volgograd": "UNK-4",
"Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zagreb": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zaporozhye": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3",
"Indian/Antananarivo": "EAT-3",
"Indian/Chagos": "UNK-6",
"Indian/Christmas": "UNK-7",
"Indian/Cocos": "UNK-6:30",
"Indian/Comoro": "EAT-3",
"Indian/Kerguelen": "UNK-5",
"Indian/Mahe": "UNK-4",
"Indian/Maldives": "UNK-5",
"Indian/Mauritius": "UNK-4",
"Indian/Mayotte": "EAT-3",
"Indian/Reunion": "UNK-4",
"Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/4",
"Pacific/Auckland": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Pacific/Bougainville": "UNK-11",
"Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45",
"Pacific/Chuuk": "UNK-10",
"Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22",
"Pacific/Efate": "UNK-11",
"Pacific/Enderbury": "UNK-13",
"Pacific/Fakaofo": "UNK-13",
"Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99",
"Pacific/Funafuti": "UNK-12",
"Pacific/Galapagos": "UNK6",
"Pacific/Gambier": "UNK9",
"Pacific/Guadalcanal": "UNK-11",
"Pacific/Guam": "ChST-10",
"Pacific/Honolulu": "HST10",
"Pacific/Kiritimati": "UNK-14",
"Pacific/Kosrae": "UNK-11",
"Pacific/Kwajalein": "UNK-12",
"Pacific/Majuro": "UNK-12",
"Pacific/Marquesas": "UNK9:30",
"Pacific/Midway": "SST11",
"Pacific/Nauru": "UNK-12",
"Pacific/Niue": "UNK11",
"Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3",
"Pacific/Noumea": "UNK-11",
"Pacific/Pago_Pago": "SST11",
"Pacific/Palau": "UNK-9",
"Pacific/Pitcairn": "UNK8",
"Pacific/Pohnpei": "UNK-11",
"Pacific/Port_Moresby": "UNK-10",
"Pacific/Rarotonga": "UNK10",
"Pacific/Saipan": "ChST-10",
"Pacific/Tahiti": "UNK10",
"Pacific/Tarawa": "UNK-12",
"Pacific/Tongatapu": "UNK-13",
"Pacific/Wake": "UNK-12",
"Pacific/Wallis": "UNK-12"
};
+2 -2
View File
@@ -2,7 +2,7 @@
import Controls from '$lib/components/Controls.svelte';
</script>
<div class="flex justify-center items-center w-full h-full">
<slot/>
<div>
<Controls />
<slot/>
</div>
+7 -3
View File
@@ -1,5 +1,9 @@
<script>
import Model from "$lib/components/Views/Model.svelte";
<script lang="ts">
import Visualization from "$lib/components/Visualization.svelte";
</script>
<Model />
<div class="grow flex">
<div class="absolute h-screen w-full top-0">
<Visualization />
</div>
</div>
+1 -1
View File
@@ -1,3 +1,3 @@
export const load = async () => {
return { title: 'Spot Micro' };
return { title: 'Controller' };
};
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import type { PageData } from '../$types';
import Demo from './Demo.svelte';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<Demo />
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
return {
title: 'Demo App'
};
}) satisfies PageLoad;
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import Light from '~icons/tabler/bulb';
import Info from '~icons/tabler/info-circle';
import Save from '~icons/tabler/device-floppy';
import Reload from '~icons/tabler/reload';
type LightState = {
led_on: boolean;
};
let lightState: LightState = { led_on: false };
let lightOn = false;
async function getLightstate() {
try {
const response = await fetch('/rest/lightState', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
const light = await response.json();
lightOn = light.led_on;
} catch (error) {
console.error('Error:', error);
}
return;
}
const ws_token = $page.data.features.security ? '?access_token=' + $user.bearer_token : '';
const lightStateSocket = new WebSocket('ws://' + $page.url.host + '/ws/lightState' + ws_token);
lightStateSocket.onopen = (event) => {
lightStateSocket.send('Hello');
};
lightStateSocket.addEventListener('close', (event) => {
const closeCode = event.code;
const closeReason = event.reason;
console.log('WebSocket closed with code:', closeCode);
console.log('Close reason:', closeReason);
notifications.error('Websocket disconnected', 5000);
});
lightStateSocket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type != 'id') {
lightState = message;
}
};
onDestroy(() => lightStateSocket.close());
onMount(() => {
getLightstate();
});
async function postLightstate() {
try {
const response = await fetch('/rest/lightState', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
},
body: JSON.stringify({ led_on: lightOn })
});
if (response.status == 200) {
notifications.success('Light state updated.', 3000);
const light = await response.json();
lightOn = light.led_on;
} else {
notifications.error('User not authorized.', 3000);
}
} catch (error) {
console.error('Error:', error);
}
}
</script>
<SettingsCard collapsible={false}>
<Light slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Light State</span>
<div class="w-full">
<h1 class="text-xl font-semibold">REST Example</h1>
<div class="alert alert-info my-2 shadow-lg">
<Info class="h-6 w-6 flex-shrink-0 stroke-current" />
<span>The form below controls the LED via the RESTful service exposed by the ESP device.</span
>
</div>
<div class="flex flex-row flex-wrap justify-between gap-x-2">
<div class="form-control w-52">
<label class="label cursor-pointer">
<span class="mr-4">Light State?</span>
<input type="checkbox" bind:checked={lightOn} class="checkbox checkbox-primary" />
</label>
</div>
<div class="flex-grow" />
<button class="btn btn-primary inline-flex items-center" on:click={postLightstate}
><Save class="mr-2 h-5 w-5" /><span>Save</span></button
>
<button class="btn btn-primary inline-flex items-center" on:click={getLightstate}
><Reload class="mr-2 h-5 w-5" /><span>Reload</span></button
>
</div>
<div class="divider" />
<h1 class="text-xl font-semibold">Websocket Example</h1>
<div class="alert alert-info my-2 shadow-lg">
<Info class="h-6 w-6 flex-shrink-0 stroke-current" />
<span
>The switch below controls the LED via the WebSocket. It will automatically update whenever
the LED state changes.</span
>
</div>
<div class="form-control w-52">
<label class="label cursor-pointer">
<span class="">Light State?</span>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={lightState.led_on}
on:change={() => {
lightStateSocket.send(JSON.stringify(lightState));
}}
/>
</label>
</div>
</div>
</SettingsCard>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import logo from '$lib/assets/logo512.png';
import InputPassword from '$lib/components/InputPassword.svelte';
import { user } from '$lib/stores/user';
import { notifications } from '$lib/components/toasts/notifications';
import { fade, fly } from 'svelte/transition';
import Login from '~icons/tabler/login';
type SignInData = {
password: string;
username: string;
};
let username = '';
let password = '';
let loginFailed = false;
let token = { access_token: '' };
async function signInUser(data: SignInData) {
try {
const response = await fetch('/rest/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);
}
}
</script>
<div class="hero from-primary/30 to-secondary/30 min-h-screen bg-gradient-to-br">
<div
class="card lg:card-side bg-base-100 face shadow-2xl {loginFailed
? 'failure border-error border-2'
: ''}"
in:fly={{ delay: 200, y: 100, duration: 500 }}
out:fade={{ duration: 200 }}
>
<figure class="bg-base-200"><img src={logo} alt="Logo" class="h-auto w-48 lg:w-64" /></figure>
<div class="card-body w-80">
<h2 class="card-title text-2xl">Login</h2>
<form class="form-control w-full max-w-xs">
<label class="label" for="user">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
class="input input-bordered w-full max-w-xs"
id="user"
bind:value={username}
/>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<InputPassword id="pwd" bind:value={password} />
<div class="card-actions mt-4 justify-end">
<button
class="btn btn-primary inline-flex items-center"
on:click={() => {
signInUser({ username: username, password: password });
}}><Login class="mr-2 h-5 w-5" /><span>Login</span></button
>
</div>
</form>
</div>
</div>
</div>
<style>
.failure {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
}
@keyframes shake {
10%,
90% {
transform: translatex(-1px);
}
20%,
80% {
transform: translatex(2px);
}
30%,
50%,
70% {
transform: translatex(-4px);
}
40%,
60% {
transform: translatex(4px);
}
}
</style>
+33 -40
View File
@@ -1,35 +1,33 @@
<script lang="ts">
// import logo from '$lib/assets/logo.png';
import Github from '~icons/tabler/brand-github';
import Discord from '~icons/tabler/brand-discord';
import Users from '~icons/tabler/users';
import Settings from '~icons/tabler/settings';
import Health from '~icons/tabler/stethoscope';
import Update from '~icons/tabler/refresh-alert';
import WiFi from '~icons/tabler/wifi';
import Router from '~icons/tabler/router';
import AP from '~icons/tabler/access-point';
import Remote from '~icons/tabler/network';
import logo from '$lib/assets/logo512.png';
import MdiGithub from '~icons/mdi/github';
import Users from '~icons/mdi/users';
import Settings from '~icons/mdi/settings';
import MdiController from '~icons/mdi/controller';
import Health from '~icons/mdi/stethoscope';
import Update from '~icons/mdi/reload';
import WiFi from '~icons/mdi/wifi';
import Router from '~icons/mdi/router';
import AP from '~icons/mdi/access-point';
import Remote from '~icons/mdi/network';
import Control from '~icons/tabler/adjustments';
import Avatar from '~icons/tabler/user-circle';
import Logout from '~icons/tabler/logout';
import Copyright from '~icons/tabler/copyright';
import Avatar from '~icons/mdi/user-circle';
import Logout from '~icons/mdi/logout';
import Copyright from '~icons/mdi/copyright';
import MQTT from '~icons/tabler/topology-star-3';
import NTP from '~icons/tabler/clock-check';
import Metrics from '~icons/tabler/report-analytics';
import NTP from '~icons/mdi/clock-check';
import Metrics from '~icons/mdi/report-bar';
import { page } from '$app/stores';
import { onMount } from 'svelte';
// import { user } from '$lib/stores/user';
import { user } from '$lib/stores/user';
import { createEventDispatcher } from 'svelte';
const appName = 'ESP32 SvelteKit';
const appName = $page.data.app_name;
const copyright = '2023 theelims';
const copyright = $page.data.copyright;
const github = { href: 'https://github.com/' + $page.data.github, active: true };
const discord = { href: '.', active: false };
type menuItem = {
title: string;
icon: object;
@@ -48,13 +46,13 @@
};
let menuItems = [
{
title: 'Demo App',
icon: Control,
href: '/demo',
feature: true,
active: false
},
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true,
active: false
},
{
title: 'Connections',
icon: Remote,
@@ -101,7 +99,7 @@
title: 'Users',
icon: Users,
href: '/user',
feature: $page.data.features.security, //&& $user.admin,
feature: $page.data.features.security && $user.admin,
active: false
},
{
@@ -131,7 +129,7 @@
($page.data.features.ota ||
$page.data.features.upload_firmware ||
$page.data.features.download_firmware) &&
(!$page.data.features.security),// || $user.admin),
(!$page.data.features.security || $user.admin),
active: false
}
]
@@ -178,7 +176,7 @@
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
on:click={() => setActiveMenuItem(menuItems, '')}
>
<!-- <img src={logo} alt="Logo" class="h-12 w-12" /> -->
<img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a>
<ul class="menu rounded-box menu-vertical flex-nowrap overflow-y-auto">
@@ -234,12 +232,12 @@
{#if $page.data.features.security}
<div class="flex items-center">
<Avatar class="h-8 w-8" />
<span class="flex-grow px-4 text-xl font-bold">$user.username</span>
<span class="flex-grow px-4 text-xl font-bold">{$user.username}</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="btn btn-ghost"
on:click={() => {
// user.invalidate();
user.invalidate();
}}
>
<Logout class="h-8 w-8 rotate-180" />
@@ -251,16 +249,11 @@
<div class="flex items-center">
{#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"
><Github class="h-5 w-5" /></a
>
{/if}
{#if discord.active}
<a href={discord.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"
><Discord class="h-5 w-5" /></a
><MdiGithub class="h-5 w-5" /></a
>
{/if}
<div class="inline-flex flex-grow items-center justify-end text-sm">
<Copyright class="h-4 w-4" /><span class="px-2">{copyright}</span>
</div>
</div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import type { PageData } from '../$types';
import MQTT from './MQTT.svelte';
import MqttConfig from './MQTTConfig.svelte';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<MQTT />
<MqttConfig />
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: "MQTT"
};
}) satisfies PageLoad;
+304
View File
@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import InputPassword from '$lib/components/InputPassword.svelte';
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 Collapsible from '$lib/components/Collapsible.svelte';
import MQTT from '~icons/tabler/topology-star-3';
import Client from '~icons/tabler/robot';
type MQTTStatus = {
enabled: boolean;
connected: boolean;
client_id: string;
last_error: string;
};
type MQTTSettings = {
enabled: boolean;
uri: string;
username: string;
password: string;
client_id: string;
keep_alive: number;
clean_session: boolean;
};
let mqttSettings: MQTTSettings;
let mqttStatus: MQTTStatus;
let formField: any;
async function getMQTTStatus() {
try {
const response = await fetch('/rest/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);
}
return mqttStatus;
}
async function getMQTTSettings() {
try {
const response = await fetch('/rest/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);
}
return mqttSettings;
}
const interval = setInterval(async () => {
getMQTTStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getMQTTSettings();
}
});
let formErrors = {
host: false,
port: false,
keep_alive: false,
topic_length: false
};
async function postMQTTSettings(data: MQTTSettings) {
try {
const response = await fetch('/rest/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('Security settings updated.', 3000);
mqttSettings = await response.json();
} else {
notifications.error('User not authorized.', 3000);
}
} catch (error) {
console.error('Error:', error);
}
return;
}
function handleSubmitMQTT() {
let valid = true;
// Validate Server URI
const regexExpURL =
/(([-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4})|(\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b))(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i;
if (!regexExpURL.test(mqttSettings.uri)) {
valid = false;
formErrors.host = true;
} else {
formErrors.host = false;
}
// Validate if port is a number and within the right range
let keepalive = Number(mqttSettings.keep_alive);
if (1 <= keepalive && keepalive <= 600) {
formErrors.keep_alive = false;
} else {
formErrors.keep_alive = true;
valid = false;
}
// Submit JSON to REST API
if (valid) {
postMQTTSettings(mqttSettings);
//alert('Form Valid');
}
}
</script>
<SettingsCard collapsible={false}>
<MQTT slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">MQTT</span>
<div class="w-full overflow-x-auto">
{#await getMQTTStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {mqttStatus.connected === true
? 'bg-success'
: 'bg-error'}"
>
<MQTT
class="h-auto w-full scale-75 {mqttStatus.connected === true
? 'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{#if mqttStatus.connected}
Connected
{:else if !mqttStatus.enabled}
MQTT Disabled
{:else}
{mqttStatus.last_error}
{/if}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Client class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Client ID</div>
<div class="text-sm opacity-75">
{mqttStatus.client_id}
</div>
</div>
</div>
</div>
{/await}
</div>
{#if !$page.data.features.security || $user.admin}
<Collapsible open={false} class="shadow-lg" on:closed={getMQTTSettings}>
<span slot="title">Change MQTT Settings</span>
<form on:submit|preventDefault={handleSubmitMQTT} novalidate bind:this={formField}>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<!-- Enable -->
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<input
type="checkbox"
bind:checked={mqttSettings.enabled}
class="checkbox checkbox-primary"
/>
<span>Enable MQTT</span>
</label>
<div class="hidden sm:block" />
<!-- URI -->
<div class="sm:col-span-2">
<label class="label" for="host">
<span class="label-text text-md">URI</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.host
? 'border-error border-2'
: ''}"
bind:value={mqttSettings.uri}
id="host"
min="3"
max="64"
required
/>
<label class="label" for="host">
<span class="label-text-alt text-error {formErrors.host ? '' : 'hidden'}"
>Must be a valid URI</span
>
</label>
</div>
<!-- Username -->
<div>
<label class="label" for="user">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
class="input input-bordered w-full"
bind:value={mqttSettings.username}
id="user"
/>
</div>
<!-- Password -->
<div>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<InputPassword bind:value={mqttSettings.password} id="pwd" />
</div>
<!-- Client ID -->
<div>
<label class="label" for="clientid">
<span class="label-text text-md">Client ID</span>
</label>
<input
type="text"
class="input input-bordered w-full"
bind:value={mqttSettings.client_id}
id="clientid"
/>
</div>
<!-- Keep Alive -->
<div>
<label class="label" for="keepalive">
<span class="label-text text-md">Keep Alive</span>
</label>
<label for="keepalive" class="input-group">
<input
type="number"
min="1"
max="600"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.keep_alive
? 'border-error border-2'
: ''}"
bind:value={mqttSettings.keep_alive}
id="keepalive"
required
/>
<span>Seconds</span>
</label>
<label for="keepalive" class="label"
><span class="label-text-alt text-error {formErrors.keep_alive ? '' : 'hidden'}"
>Must be between 1 and 600 seconds</span
></label
>
</div>
<!-- Clean Session -->
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<input
type="checkbox"
bind:checked={mqttSettings.clean_session}
class="checkbox checkbox-primary"
/>
<span class="">Clean Session?</span>
</label>
</div>
<div class="divider mb-2 mt-0" />
<div class="mx-4 flex flex-wrap justify-end gap-2">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</Collapsible>
{/if}
</SettingsCard>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
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';
type BrokerSettings = {
mqtt_path: string;
name: string;
unique_id: string;
};
let brokerSettings: BrokerSettings;
let formField: any;
async function getBrokerSettings() {
try {
const response = await fetch('/rest/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;
}
let formErrors = {
uid: false,
path: false,
name: false
};
async function postBrokerSettings() {
try {
const response = await fetch('/rest/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;
}
function handleSubmitBroker() {
let valid = true;
// Validate unique ID
if (brokerSettings.unique_id.length < 3 || brokerSettings.unique_id.length > 32) {
valid = false;
formErrors.uid = true;
} else {
formErrors.uid = false;
}
// Validate name
if (brokerSettings.name.length < 3 || brokerSettings.name.length > 32) {
valid = false;
formErrors.name = true;
} else {
formErrors.name = false;
}
// Validate MQTT Path
if (brokerSettings.mqtt_path.length > 64) {
valid = false;
formErrors.path = true;
} else {
formErrors.path = false;
}
// Submit JSON to REST API
if (valid) {
postBrokerSettings();
//alert('Form Valid');
}
}
</script>
<SettingsCard collapsible={true} open={false}>
<MQTT slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">MQTT Broker Settings</span>
<div class="w-full overflow-x-auto">
{#await getBrokerSettings()}
<Spinner />
{:then nothing}
<form
on:submit|preventDefault={handleSubmitBroker}
novalidate
bind:this={formField}
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="alert alert-info my-2 shadow-lg">
<Info class="h-6 w-6 flex-shrink-0 stroke-current" />
<span
>The LED is controllable via MQTT with the demo project designed to work with Home
Assistant's auto discovery feature.</span
>
</div>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4">
<div>
<label class="label" for="uid">
<span class="label-text text-md">Unique ID</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.uid
? 'border-error border-2'
: ''}"
bind:value={brokerSettings.unique_id}
id="uid"
min="3"
max="32"
required
/>
<label class="label" for="uid">
<span class="label-text-alt text-error {formErrors.uid ? '' : 'hidden'}"
>Unique ID must be between 3 and 32 characters long</span
>
</label>
</div>
<div>
<label class="label" for="name">
<span class="label-text text-md">Name</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.name
? 'border-error border-2'
: ''}"
bind:value={brokerSettings.name}
id="name"
min="3"
max="32"
required
/>
<label class="label" for="name">
<span class="label-text-alt text-error {formErrors.name ? '' : 'hidden'}"
>Name must be between 3 and 32 characters long</span
>
</label>
</div>
<div>
<label class="label" for="path">
<span class="label-text text-md">MQTT Path</span>
</label>
<input
type="text"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.path
? 'border-error border-2'
: ''}"
bind:value={brokerSettings.mqtt_path}
id="path"
min="0"
max="64"
required
/>
<label class="label" for="path">
<span class="label-text-alt text-error {formErrors.path ? '' : 'hidden'}"
>MQTT path is limited to 64 characters</span
>
</label>
</div>
</div>
<div class="divider mb-2 mt-0" />
<div class="mx-4 flex flex-wrap justify-end gap-2">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
{/await}
</div>
</SettingsCard>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import type { PageData } from '../$types';
import NTP from './NTP.svelte';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<NTP />
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'NTP'
};
}) satisfies PageLoad;
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import Collapsible from '$lib/components/Collapsible.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications';
import type { TimeZones } from './timezones';
import { TIME_ZONES } from './timezones';
import NTP from '~icons/tabler/clock-check';
import Server from '~icons/tabler/server';
import Clock from '~icons/tabler/clock';
import UTC from '~icons/tabler/clock-pin';
import Stopwatch from '~icons/tabler/24-hours';
type NTPStatus = {
status: number;
utc_time: string;
local_time: string;
server: string;
uptime: number;
};
type NTPSettings = {
enabled: boolean;
server: string;
tz_label: string;
tz_format: string;
};
let ntpSettings: NTPSettings;
let ntpStatus: NTPStatus;
async function getNTPStatus() {
try {
const response = await fetch('/rest/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;
}
async function getNTPSettings() {
try {
const response = await fetch('/rest/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 interval = setInterval(async () => {
getNTPStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getNTPSettings();
}
});
let formField: any;
let formErrors = {
server: false
};
async function postNTPSettings(data: NTPSettings) {
try {
const response = await fetch('/rest/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);
}
}
function handleSubmitNTP() {
let valid = true;
// Validate Server
// RegEx for IPv4
const regexExpIPv4 =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/;
const regexExpURL =
/[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/i;
if (!regexExpURL.test(ntpSettings.server) && !regexExpIPv4.test(ntpSettings.server)) {
valid = false;
formErrors.server = true;
} else {
formErrors.server = false;
}
ntpSettings.tz_format = TIME_ZONES[ntpSettings.tz_label];
// Submit JSON to REST API
if (valid) {
postNTPSettings(ntpSettings);
//alert('Form Valid');
}
}
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;
}
</script>
<SettingsCard collapsible={false}>
<Clock slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Network Time</span>
<div class="w-full overflow-x-auto">
{#await getNTPStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {ntpStatus.status === 1
? 'bg-success'
: 'bg-error'}"
>
<NTP
class="h-auto w-full scale-75 {ntpStatus.status === 1
? 'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{ntpStatus.status === 1 ? 'Active' : 'Inactive'}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Server class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">NTP Server</div>
<div class="text-sm opacity-75">
{ntpStatus.server}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Clock class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Local Time</div>
<div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeStyle: 'long'
}).format(new Date(ntpStatus.local_time))}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<UTC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">UTC Time</div>
<div class="text-sm opacity-75">
{new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeStyle: 'long',
timeZone: 'UTC'
}).format(new Date(ntpStatus.utc_time))}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Uptime</div>
<div class="text-sm opacity-75">
{convertSeconds(ntpStatus.uptime)}
</div>
</div>
</div>
</div>
{/await}
</div>
{#if !$page.data.features.security || $user.admin}
<Collapsible open={false} class="shadow-lg" on:closed={getNTPSettings}>
<span slot="title">Change NTP Settings</span>
<form
class="form-control w-full"
on:submit|preventDefault={handleSubmitNTP}
novalidate
bind:this={formField}
>
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={ntpSettings.enabled}
class="checkbox checkbox-primary"
/>
<span class="">Enable NTP</span>
</label>
<label class="label" for="server">
<span class="label-text text-md">Server</span>
</label>
<input
type="text"
min="3"
max="64"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrors.server
? 'border-error border-2'
: ''}"
bind:value={ntpSettings.server}
id="server"
required
/>
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.server ? '' : 'hidden'}"
>Must be a valid IPv4 address or URL</span
>
</label>
<label class="label" for="tz">
<span class="label-text text-md">Pick Time Zone</span>
</label>
<select class="select select-bordered" bind:value={ntpSettings.tz_label} id="tz">
{#each Object.entries(TIME_ZONES) as [tz_label, tz_format]}
<option value={tz_label}>{tz_label}</option>
{/each}
</select>
<div class="mt-6 place-self-end">
<button class="btn btn-primary" type="submit">Apply Settings</button>
</div>
</form>
</Collapsible>
{/if}
</SettingsCard>
+466
View File
@@ -0,0 +1,466 @@
export type TimeZones = {
[name: string]: string
};
export const TIME_ZONES: TimeZones = {
"Africa/Abidjan": "GMT0",
"Africa/Accra": "GMT0",
"Africa/Addis_Ababa": "EAT-3",
"Africa/Algiers": "CET-1",
"Africa/Asmara": "EAT-3",
"Africa/Bamako": "GMT0",
"Africa/Bangui": "WAT-1",
"Africa/Banjul": "GMT0",
"Africa/Bissau": "GMT0",
"Africa/Blantyre": "CAT-2",
"Africa/Brazzaville": "WAT-1",
"Africa/Bujumbura": "CAT-2",
"Africa/Cairo": "EET-2",
"Africa/Casablanca": "UNK-1",
"Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Africa/Conakry": "GMT0",
"Africa/Dakar": "GMT0",
"Africa/Dar_es_Salaam": "EAT-3",
"Africa/Djibouti": "EAT-3",
"Africa/Douala": "WAT-1",
"Africa/El_Aaiun": "UNK-1",
"Africa/Freetown": "GMT0",
"Africa/Gaborone": "CAT-2",
"Africa/Harare": "CAT-2",
"Africa/Johannesburg": "SAST-2",
"Africa/Juba": "EAT-3",
"Africa/Kampala": "EAT-3",
"Africa/Khartoum": "CAT-2",
"Africa/Kigali": "CAT-2",
"Africa/Kinshasa": "WAT-1",
"Africa/Lagos": "WAT-1",
"Africa/Libreville": "WAT-1",
"Africa/Lome": "GMT0",
"Africa/Luanda": "WAT-1",
"Africa/Lubumbashi": "CAT-2",
"Africa/Lusaka": "CAT-2",
"Africa/Malabo": "WAT-1",
"Africa/Maputo": "CAT-2",
"Africa/Maseru": "SAST-2",
"Africa/Mbabane": "SAST-2",
"Africa/Mogadishu": "EAT-3",
"Africa/Monrovia": "GMT0",
"Africa/Nairobi": "EAT-3",
"Africa/Ndjamena": "WAT-1",
"Africa/Niamey": "WAT-1",
"Africa/Nouakchott": "GMT0",
"Africa/Ouagadougou": "GMT0",
"Africa/Porto-Novo": "WAT-1",
"Africa/Sao_Tome": "GMT0",
"Africa/Tripoli": "EET-2",
"Africa/Tunis": "CET-1",
"Africa/Windhoek": "CAT-2",
"America/Adak": "HST10HDT,M3.2.0,M11.1.0",
"America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Anguilla": "AST4",
"America/Antigua": "AST4",
"America/Araguaina": "UNK3",
"America/Argentina/Buenos_Aires": "UNK3",
"America/Argentina/Catamarca": "UNK3",
"America/Argentina/Cordoba": "UNK3",
"America/Argentina/Jujuy": "UNK3",
"America/Argentina/La_Rioja": "UNK3",
"America/Argentina/Mendoza": "UNK3",
"America/Argentina/Rio_Gallegos": "UNK3",
"America/Argentina/Salta": "UNK3",
"America/Argentina/San_Juan": "UNK3",
"America/Argentina/San_Luis": "UNK3",
"America/Argentina/Tucuman": "UNK3",
"America/Argentina/Ushuaia": "UNK3",
"America/Aruba": "AST4",
"America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0",
"America/Atikokan": "EST5",
"America/Bahia": "UNK3",
"America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0",
"America/Barbados": "AST4",
"America/Belem": "UNK3",
"America/Belize": "CST6",
"America/Blanc-Sablon": "AST4",
"America/Boa_Vista": "UNK4",
"America/Bogota": "UNK5",
"America/Boise": "MST7MDT,M3.2.0,M11.1.0",
"America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0",
"America/Campo_Grande": "UNK4",
"America/Cancun": "EST5",
"America/Caracas": "UNK4",
"America/Cayenne": "UNK3",
"America/Cayman": "EST5",
"America/Chicago": "CST6CDT,M3.2.0,M11.1.0",
"America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0",
"America/Costa_Rica": "CST6",
"America/Creston": "MST7",
"America/Cuiaba": "UNK4",
"America/Curacao": "AST4",
"America/Danmarkshavn": "GMT0",
"America/Dawson": "MST7",
"America/Dawson_Creek": "MST7",
"America/Denver": "MST7MDT,M3.2.0,M11.1.0",
"America/Detroit": "EST5EDT,M3.2.0,M11.1.0",
"America/Dominica": "AST4",
"America/Edmonton": "MST7MDT,M3.2.0,M11.1.0",
"America/Eirunepe": "UNK5",
"America/El_Salvador": "CST6",
"America/Fort_Nelson": "MST7",
"America/Fortaleza": "UNK3",
"America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1",
"America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0",
"America/Grenada": "AST4",
"America/Guadeloupe": "AST4",
"America/Guatemala": "CST6",
"America/Guayaquil": "UNK5",
"America/Guyana": "UNK4",
"America/Halifax": "AST4ADT,M3.2.0,M11.1.0",
"America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1",
"America/Hermosillo": "MST7",
"America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Knox": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Marengo": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Petersburg": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Tell_City": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Vevay": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Vincennes": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Winamac": "EST5EDT,M3.2.0,M11.1.0",
"America/Inuvik": "MST7MDT,M3.2.0,M11.1.0",
"America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0",
"America/Jamaica": "EST5",
"America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Kentucky/Louisville": "EST5EDT,M3.2.0,M11.1.0",
"America/Kentucky/Monticello": "EST5EDT,M3.2.0,M11.1.0",
"America/Kralendijk": "AST4",
"America/La_Paz": "UNK4",
"America/Lima": "UNK5",
"America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0",
"America/Lower_Princes": "AST4",
"America/Maceio": "UNK3",
"America/Managua": "CST6",
"America/Manaus": "UNK4",
"America/Marigot": "AST4",
"America/Martinique": "AST4",
"America/Matamoros": "CST6CDT,M3.2.0,M11.1.0",
"America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0",
"America/Menominee": "CST6CDT,M3.2.0,M11.1.0",
"America/Merida": "CST6CDT,M4.1.0,M10.5.0",
"America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0",
"America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0",
"America/Moncton": "AST4ADT,M3.2.0,M11.1.0",
"America/Monterrey": "CST6CDT,M4.1.0,M10.5.0",
"America/Montevideo": "UNK3",
"America/Montreal": "EST5EDT,M3.2.0,M11.1.0",
"America/Montserrat": "AST4",
"America/Nassau": "EST5EDT,M3.2.0,M11.1.0",
"America/New_York": "EST5EDT,M3.2.0,M11.1.0",
"America/Nipigon": "EST5EDT,M3.2.0,M11.1.0",
"America/Nome": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Noronha": "UNK2",
"America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/Center": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/New_Salem": "CST6CDT,M3.2.0,M11.1.0",
"America/Ojinaga": "MST7MDT,M3.2.0,M11.1.0",
"America/Panama": "EST5",
"America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0",
"America/Paramaribo": "UNK3",
"America/Phoenix": "MST7",
"America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0",
"America/Port_of_Spain": "AST4",
"America/Porto_Velho": "UNK4",
"America/Puerto_Rico": "AST4",
"America/Punta_Arenas": "UNK3",
"America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0",
"America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0",
"America/Recife": "UNK3",
"America/Regina": "CST6",
"America/Resolute": "CST6CDT,M3.2.0,M11.1.0",
"America/Rio_Branco": "UNK5",
"America/Santarem": "UNK3",
"America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24",
"America/Santo_Domingo": "AST4",
"America/Sao_Paulo": "UNK3",
"America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0",
"America/St_Barthelemy": "AST4",
"America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0",
"America/St_Kitts": "AST4",
"America/St_Lucia": "AST4",
"America/St_Thomas": "AST4",
"America/St_Vincent": "AST4",
"America/Swift_Current": "CST6",
"America/Tegucigalpa": "CST6",
"America/Thule": "AST4ADT,M3.2.0,M11.1.0",
"America/Thunder_Bay": "EST5EDT,M3.2.0,M11.1.0",
"America/Tijuana": "PST8PDT,M3.2.0,M11.1.0",
"America/Toronto": "EST5EDT,M3.2.0,M11.1.0",
"America/Tortola": "AST4",
"America/Vancouver": "PST8PDT,M3.2.0,M11.1.0",
"America/Whitehorse": "MST7",
"America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0",
"America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0",
"Antarctica/Casey": "UNK-8",
"Antarctica/Davis": "UNK-7",
"Antarctica/DumontDUrville": "UNK-10",
"Antarctica/Macquarie": "UNK-11",
"Antarctica/Mawson": "UNK-5",
"Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Antarctica/Palmer": "UNK3",
"Antarctica/Rothera": "UNK3",
"Antarctica/Syowa": "UNK-3",
"Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3",
"Antarctica/Vostok": "UNK-6",
"Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Asia/Aden": "UNK-3",
"Asia/Almaty": "UNK-6",
"Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1",
"Asia/Anadyr": "UNK-12",
"Asia/Aqtau": "UNK-5",
"Asia/Aqtobe": "UNK-5",
"Asia/Ashgabat": "UNK-5",
"Asia/Atyrau": "UNK-5",
"Asia/Baghdad": "UNK-3",
"Asia/Bahrain": "UNK-3",
"Asia/Baku": "UNK-4",
"Asia/Bangkok": "UNK-7",
"Asia/Barnaul": "UNK-7",
"Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0",
"Asia/Bishkek": "UNK-6",
"Asia/Brunei": "UNK-8",
"Asia/Chita": "UNK-9",
"Asia/Choibalsan": "UNK-8",
"Asia/Colombo": "UNK-5:30",
"Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0",
"Asia/Dhaka": "UNK-6",
"Asia/Dili": "UNK-9",
"Asia/Dubai": "UNK-4",
"Asia/Dushanbe": "UNK-5",
"Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Gaza": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Hebron": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Ho_Chi_Minh": "UNK-7",
"Asia/Hong_Kong": "HKT-8",
"Asia/Hovd": "UNK-7",
"Asia/Irkutsk": "UNK-8",
"Asia/Jakarta": "WIB-7",
"Asia/Jayapura": "WIT-9",
"Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0",
"Asia/Kabul": "UNK-4:30",
"Asia/Kamchatka": "UNK-12",
"Asia/Karachi": "PKT-5",
"Asia/Kathmandu": "UNK-5:45",
"Asia/Khandyga": "UNK-9",
"Asia/Kolkata": "IST-5:30",
"Asia/Krasnoyarsk": "UNK-7",
"Asia/Kuala_Lumpur": "UNK-8",
"Asia/Kuching": "UNK-8",
"Asia/Kuwait": "UNK-3",
"Asia/Macau": "CST-8",
"Asia/Magadan": "UNK-11",
"Asia/Makassar": "WITA-8",
"Asia/Manila": "PST-8",
"Asia/Muscat": "UNK-4",
"Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Novokuznetsk": "UNK-7",
"Asia/Novosibirsk": "UNK-7",
"Asia/Omsk": "UNK-6",
"Asia/Oral": "UNK-5",
"Asia/Phnom_Penh": "UNK-7",
"Asia/Pontianak": "WIB-7",
"Asia/Pyongyang": "KST-9",
"Asia/Qatar": "UNK-3",
"Asia/Qyzylorda": "UNK-5",
"Asia/Riyadh": "UNK-3",
"Asia/Sakhalin": "UNK-11",
"Asia/Samarkand": "UNK-5",
"Asia/Seoul": "KST-9",
"Asia/Shanghai": "CST-8",
"Asia/Singapore": "UNK-8",
"Asia/Srednekolymsk": "UNK-11",
"Asia/Taipei": "CST-8",
"Asia/Tashkent": "UNK-5",
"Asia/Tbilisi": "UNK-4",
"Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24",
"Asia/Thimphu": "UNK-6",
"Asia/Tokyo": "JST-9",
"Asia/Tomsk": "UNK-7",
"Asia/Ulaanbaatar": "UNK-8",
"Asia/Urumqi": "UNK-6",
"Asia/Ust-Nera": "UNK-10",
"Asia/Vientiane": "UNK-7",
"Asia/Vladivostok": "UNK-10",
"Asia/Yakutsk": "UNK-9",
"Asia/Yangon": "UNK-6:30",
"Asia/Yekaterinburg": "UNK-5",
"Asia/Yerevan": "UNK-4",
"Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0",
"Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Cape_Verde": "UNK1",
"Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Reykjavik": "GMT0",
"Atlantic/South_Georgia": "UNK2",
"Atlantic/St_Helena": "GMT0",
"Atlantic/Stanley": "UNK3",
"Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Brisbane": "AEST-10",
"Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Currie": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Darwin": "ACST-9:30",
"Australia/Eucla": "UNK-8:45",
"Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Lindeman": "AEST-10",
"Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0",
"Australia/Melbourne": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Perth": "AWST-8",
"Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Etc/GMT": "GMT0",
"Etc/GMT+0": "GMT0",
"Etc/GMT+1": "UNK1",
"Etc/GMT+10": "UNK10",
"Etc/GMT+11": "UNK11",
"Etc/GMT+12": "UNK12",
"Etc/GMT+2": "UNK2",
"Etc/GMT+3": "UNK3",
"Etc/GMT+4": "UNK4",
"Etc/GMT+5": "UNK5",
"Etc/GMT+6": "UNK6",
"Etc/GMT+7": "UNK7",
"Etc/GMT+8": "UNK8",
"Etc/GMT+9": "UNK9",
"Etc/GMT-0": "GMT0",
"Etc/GMT-1": "UNK-1",
"Etc/GMT-10": "UNK-10",
"Etc/GMT-11": "UNK-11",
"Etc/GMT-12": "UNK-12",
"Etc/GMT-13": "UNK-13",
"Etc/GMT-14": "UNK-14",
"Etc/GMT-2": "UNK-2",
"Etc/GMT-3": "UNK-3",
"Etc/GMT-4": "UNK-4",
"Etc/GMT-5": "UNK-5",
"Etc/GMT-6": "UNK-6",
"Etc/GMT-7": "UNK-7",
"Etc/GMT-8": "UNK-8",
"Etc/GMT-9": "UNK-9",
"Etc/GMT0": "GMT0",
"Etc/Greenwich": "GMT0",
"Etc/UCT": "UTC0",
"Etc/UTC": "UTC0",
"Etc/Universal": "UTC0",
"Etc/Zulu": "UTC0",
"Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Andorra": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Astrakhan": "UNK-4",
"Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Belgrade": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Berlin": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bratislava": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Brussels": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bucharest": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Busingen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Chisinau": "EET-2EEST,M3.5.0,M10.5.0/3",
"Europe/Copenhagen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Dublin": "IST-1GMT0,M10.5.0,M3.5.0/1",
"Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Isle_of_Man": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Istanbul": "UNK-3",
"Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Kaliningrad": "EET-2",
"Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Kirov": "UNK-3",
"Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0",
"Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/London": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Madrid": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Malta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Minsk": "UNK-3",
"Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Moscow": "MSK-3",
"Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Paris": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Podgorica": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Prague": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Riga": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Samara": "UNK-4",
"Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sarajevo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Saratov": "UNK-4",
"Europe/Simferopol": "MSK-3",
"Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sofia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Ulyanovsk": "UNK-4",
"Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Vaduz": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vatican": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vienna": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Volgograd": "UNK-4",
"Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zagreb": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zaporozhye": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3",
"Indian/Antananarivo": "EAT-3",
"Indian/Chagos": "UNK-6",
"Indian/Christmas": "UNK-7",
"Indian/Cocos": "UNK-6:30",
"Indian/Comoro": "EAT-3",
"Indian/Kerguelen": "UNK-5",
"Indian/Mahe": "UNK-4",
"Indian/Maldives": "UNK-5",
"Indian/Mauritius": "UNK-4",
"Indian/Mayotte": "EAT-3",
"Indian/Reunion": "UNK-4",
"Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/4",
"Pacific/Auckland": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Pacific/Bougainville": "UNK-11",
"Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45",
"Pacific/Chuuk": "UNK-10",
"Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22",
"Pacific/Efate": "UNK-11",
"Pacific/Enderbury": "UNK-13",
"Pacific/Fakaofo": "UNK-13",
"Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99",
"Pacific/Funafuti": "UNK-12",
"Pacific/Galapagos": "UNK6",
"Pacific/Gambier": "UNK9",
"Pacific/Guadalcanal": "UNK-11",
"Pacific/Guam": "ChST-10",
"Pacific/Honolulu": "HST10",
"Pacific/Kiritimati": "UNK-14",
"Pacific/Kosrae": "UNK-11",
"Pacific/Kwajalein": "UNK-12",
"Pacific/Majuro": "UNK-12",
"Pacific/Marquesas": "UNK9:30",
"Pacific/Midway": "SST11",
"Pacific/Nauru": "UNK-12",
"Pacific/Niue": "UNK11",
"Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3",
"Pacific/Noumea": "UNK-11",
"Pacific/Pago_Pago": "SST11",
"Pacific/Palau": "UNK-9",
"Pacific/Pitcairn": "UNK8",
"Pacific/Pohnpei": "UNK-11",
"Pacific/Port_Moresby": "UNK-10",
"Pacific/Rarotonga": "UNK10",
"Pacific/Saipan": "ChST-10",
"Pacific/Tahiti": "UNK10",
"Pacific/Tarawa": "UNK-12",
"Pacific/Tongatapu": "UNK-13",
"Pacific/Wake": "UNK-12",
"Pacific/Wallis": "UNK-12"
};
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
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';
import Power from '~icons/tabler/power';
import Cancel from '~icons/tabler/x';
import RssiIndicator from '$lib/components/RSSIIndicator.svelte';
import BatteryIndicator from '$lib/components/BatteryIndicator.svelte';
import UpdateIndicator from '$lib/components/UpdateIndicator.svelte';
async function postSleep() {
const response = await fetch('/rest/sleep', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic'
}
});
}
function confirmSleep() {
openModal(ConfirmDialog, {
title: 'Confirm Power Down',
message: 'Are you sure you want to switch off the device?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Switch Off', icon: Power }
},
onConfirm: () => {
closeModal();
postSleep();
}
});
}
</script>
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16">
<div class="flex-1">
<!-- Page Hamburger Icon here -->
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button"
><Hamburger class="h-6 w-auto" /></label
>
<span class="px-2 text-xl font-bold lg:text-2xl">{$page.data.title}</span>
</div>
<div class="indicator flex-none">
<UpdateIndicator />
</div>
<div class="flex-none">
{#if $telemetry.rssi.disconnected}
<WiFiOff class="h-7 w-7" />
{:else}
<RssiIndicator showDBm={false} rssi_dbm={$telemetry.rssi.rssi} class="h-7 w-7" />
{/if}
</div>
{#if $page.data.features.battery}
<div class="flex-none">
<BatteryIndicator
charging={$telemetry.battery.charging}
soc={$telemetry.battery.soc}
class="h-7 w-7"
/>
</div>
{/if}
{#if $page.data.features.sleep}
<div class="flex-none">
<button class="btn btn-square btn-ghost h-9 w-10" on:click={confirmSleep}>
<Power class="text-error h-9 w-9" />
</button>
</div>
{/if}
</div>
View File
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
import { goto } from '$app/navigation';
export const load = (async () => {
goto('/');
return;
}) satisfies PageLoad;
-1
View File
@@ -1 +0,0 @@
<div>LOGS</div>
+20 -1
View File
@@ -1 +1,20 @@
<div>metrics</div>
<script lang="ts">
import type { PageData } from './$types';
import SystemMetrics from './SystemMetrics.svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
export let data: PageData;
if (!$page.data.features.analytics) {
goto('/');
}
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<SystemMetrics />
</div>
+5
View File
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return { title: 'System Metrics' };
}) satisfies PageLoad;
@@ -0,0 +1,303 @@
<script lang="ts">
import { onDestroy, 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 Metrics from '~icons/tabler/report-analytics';
import { daisyColor } from '$lib/DaisyUiHelper';
import { analytics } from '$lib/stores/analytics';
Chart.register(...registerables);
let heapChartElement: HTMLCanvasElement;
let heapChart: Chart;
let filesystemChartElement: HTMLCanvasElement;
let filesystemChart: Chart;
let temperatureChartElement: HTMLCanvasElement;
let temperatureChart: Chart;
onMount(() => {
heapChart = new Chart(heapChartElement, {
type: 'line',
data: {
labels: $analytics.uptime,
datasets: [
{
label: 'Free Heap',
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.free_heap,
yAxisID: 'y'
},
{
label: 'Max Alloc Heap',
borderColor: daisyColor('--s'),
backgroundColor: daisyColor('--s', 50),
borderWidth: 2,
data: $analytics.max_alloc_heap,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Heap [kb]',
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: Math.round($analytics.total_heap[0]),
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
filesystemChart = new Chart(filesystemChartElement, {
type: 'line',
data: {
labels: $analytics.uptime,
datasets: [
{
label: 'File System Used',
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data: $analytics.fs_used,
yAxisID: 'y'
}
]
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 1
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'File System [kb]',
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: Math.round($analytics.fs_total[0]),
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
temperatureChart = new Chart(temperatureChartElement, {
type: 'line',
data: {
labels: $analytics.uptime,
datasets: [
{
label: 'Core Temperature',
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 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: 1
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: 'Core Temperature [°C]',
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
suggestedMin: 20,
suggestedMax: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
});
setInterval(() => {
updateData(), 2000;
});
});
function updateData() {
heapChart.data.labels = $analytics.uptime;
heapChart.data.datasets[0].data = $analytics.free_heap;
heapChart.data.datasets[1].data = $analytics.max_alloc_heap;
heapChart.update('none');
filesystemChart.data.labels = $analytics.uptime;
filesystemChart.data.datasets[0].data = $analytics.fs_used;
filesystemChart.update('none');
temperatureChart.data.labels = $analytics.uptime;
temperatureChart.data.datasets[0].data = $analytics.core_temp;
temperatureChart.update('none');
}
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;
}
</script>
<SettingsCard collapsible={false}>
<Metrics slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">System Metrics</span>
<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} />
</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} />
</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} />
</div>
</div>
</SettingsCard>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { PageData } from './$types';
import SystemStatus from './SystemStatus.svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<SystemStatus />
</div>
+5
View File
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return { title: 'System Status' };
}) satisfies PageLoad;
@@ -0,0 +1,406 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { openModal, closeModal } from 'svelte-modals';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
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 CPU from '~icons/tabler/cpu';
import CPP from '~icons/tabler/binary';
import Power from '~icons/tabler/reload';
import Sleep from '~icons/tabler/zzz';
import FactoryReset from '~icons/tabler/refresh-dot';
import Speed from '~icons/tabler/activity';
import Flash from '~icons/tabler/device-sd-card';
import Pyramid from '~icons/tabler/pyramid';
import Sketch from '~icons/tabler/chart-pie';
import Folder from '~icons/tabler/folder';
import Heap from '~icons/tabler/box-model';
import Cancel from '~icons/tabler/x';
import Temperature from '~icons/tabler/temperature';
import Health from '~icons/tabler/stethoscope';
import Stopwatch from '~icons/tabler/24-hours';
import SDK from '~icons/tabler/sdk';
type SystemStatus = {
esp_platform: string;
firmware_version: string;
max_alloc_heap: number;
psram_size: number;
free_psram: number;
cpu_freq_mhz: number;
cpu_type: string;
cpu_rev: number;
cpu_cores: number;
free_heap: number;
min_free_heap: number;
sketch_size: number;
free_sketch_space: number;
sdk_version: string;
arduino_version: string;
flash_chip_size: number;
flash_chip_speed: number;
fs_total: number;
fs_used: number;
core_temp: number;
cpu_reset_reason: string;
uptime: number;
};
let systemStatus: SystemStatus;
async function getSystemStatus() {
try {
const response = await fetch('/rest/systemStatus', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
systemStatus = await response.json();
} catch (error) {
console.log('Error:', error);
}
return systemStatus;
}
const interval = setInterval(async () => {
getSystemStatus();
}, 5000);
onMount(() => getSystemStatus());
onDestroy(() => clearInterval(interval));
async function postRestart() {
const response = await fetch('/rest/restart', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic'
}
});
}
function confirmRestart() {
openModal(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: () => {
closeModal();
postRestart();
}
});
}
async function postFactoryReset() {
const response = await fetch('/rest/factoryReset', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic'
}
});
}
function confirmReset() {
openModal(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: () => {
closeModal();
postFactoryReset();
}
});
}
async function postSleep() {
const response = await fetch('/rest/sleep', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic'
}
});
}
function confirmSleep() {
openModal(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: () => {
closeModal();
postSleep();
}
});
}
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;
}
</script>
<SettingsCard collapsible={false}>
<Health slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">System Status</span>
<div class="w-full overflow-x-auto">
{#await getSystemStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<CPU class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Chip</div>
<div class="text-sm opacity-75">
{systemStatus.cpu_type} Rev {systemStatus.cpu_rev}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<SDK class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">SDK Version</div>
<div class="text-sm opacity-75">
ESP-IDF {systemStatus.sdk_version} / Arduino {systemStatus.arduino_version}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<CPP class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Firmware Version</div>
<div class="text-sm opacity-75">
{systemStatus.firmware_version}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Speed class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">CPU Frequency</div>
<div class="text-sm opacity-75">
{systemStatus.cpu_freq_mhz} MHz {systemStatus.cpu_cores == 2
? 'Dual Core'
: 'Single Core'}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Heap class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Heap (Free / Max Alloc)</div>
<div class="text-sm opacity-75">
{systemStatus.free_heap.toLocaleString('en-US')} / {systemStatus.max_alloc_heap.toLocaleString(
'en-US'
)} bytes
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Pyramid class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">PSRAM (Size / Free)</div>
<div class="text-sm opacity-75">
{systemStatus.psram_size.toLocaleString('en-US')} / {systemStatus.psram_size.toLocaleString(
'en-US'
)} bytes
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Sketch class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Sketch (Used / Free)</div>
<div class="flex flex-wrap justify-start gap-1 text-sm opacity-75">
<span>
{((systemStatus.sketch_size / systemStatus.free_sketch_space) * 100).toFixed(1)} % of
{(systemStatus.free_sketch_space / 1000000).toLocaleString('en-US')} MB used
</span>
<span>
({(
(systemStatus.free_sketch_space - systemStatus.sketch_size) /
1000000
).toLocaleString('en-US')} MB free)
</span>
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Flash class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Flash Chip (Size / Speed)</div>
<div class="text-sm opacity-75">
{(systemStatus.flash_chip_size / 1000000).toLocaleString('en-US')} MB / {(
systemStatus.flash_chip_speed / 1000000
).toLocaleString('en-US')} MHz
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Folder class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">File System (Used / Total)</div>
<div class="flex flex-wrap justify-start gap-1 text-sm opacity-75">
<span
>{((systemStatus.fs_used / systemStatus.fs_total) * 100).toFixed(1)} % of {(
systemStatus.fs_total / 1000000
).toLocaleString('en-US')} MB used</span
>
<span
>({((systemStatus.fs_total - systemStatus.fs_used) / 1000000).toLocaleString(
'en-US'
)}
MB free)</span
>
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Temperature class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Core Temperature</div>
<div class="text-sm opacity-75">
{systemStatus.core_temp == 53.33 ? 'NaN' : systemStatus.core_temp.toFixed(2) + ' °C'}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Uptime</div>
<div class="text-sm opacity-75">
{convertSeconds(systemStatus.uptime)}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Power class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Reset Reason</div>
<div class="text-sm opacity-75">
{systemStatus.cpu_reset_reason}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Stopwatch class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Uptime</div>
<div class="text-sm opacity-75">
{convertSeconds(systemStatus.uptime)}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 flex-none">
<Power class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Reset Reason</div>
<div class="text-sm opacity-75">
{systemStatus.cpu_reset_reason}
</div>
</div>
</div>
</div>
{/await}
</div>
<div class="mt-4 flex flex-wrap justify-end gap-2">
{#if $page.data.features.sleep}
<button class="btn btn-primary inline-flex items-center" on:click={confirmSleep}
><Sleep class="mr-2 h-5 w-5" /><span>Sleep</span></button
>
{/if}
{#if !$page.data.features.security || $user.admin}
<button class="btn btn-primary inline-flex items-center" on:click={confirmRestart}
><Power class="mr-2 h-5 w-5" /><span>Restart</span></button
>
<button class="btn btn-secondary inline-flex items-center" on:click={confirmReset}
><FactoryReset class="mr-2 h-5 w-5" /><span>Factory Reset</span></button
>
{/if}
</div>
</SettingsCard>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { PageData } from './$types';
import UploadFirmware from './UploadFirmware.svelte';
import GithubFirmwareManager from './GithubFirmwareManager.svelte';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
{#if $page.data.features.download_firmware && (!$page.data.features.security || $user.admin)}
<GithubFirmwareManager />
{/if}
{#if $page.data.features.upload_firmware && (!$page.data.features.security || $user.admin)}
<UploadFirmware />
{/if}
</div>
+5
View File
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return { title: 'Firmware Update' };
}) satisfies PageLoad;
@@ -0,0 +1,165 @@
<script lang="ts">
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { openModal, closeModal, closeAllModals } 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 Github from '~icons/tabler/brand-github';
import CloudDown from '~icons/tabler/cloud-download';
import Cancel from '~icons/tabler/x';
import Prerelease from '~icons/tabler/test-pipe';
import Error from '~icons/tabler/circle-x';
import { compareVersions } from 'compare-versions';
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte';
import { assets } from '$app/paths';
import InfoDialog from '$lib/components/InfoDialog.svelte';
import Check from '~icons/tabler/check';
async function getGithubAPI() {
try {
const githubResponse = await fetch(
'https://api.github.com/repos/' + $page.data.github + '/releases',
{
method: 'GET',
headers: {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
}
);
const results = await githubResponse.json();
return results;
} catch (error) {
console.error('Error:', error);
}
return;
}
async function postGithubDownload(url: string) {
try {
const apiResponse = await fetch('/rest/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);
}
}
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($page.data.features.firmware_built_target)
) {
url = assets[i].browser_download_url;
}
}
if (url === '') {
// if no asset was found, use the first one
openModal(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: () => closeModal()
});
return;
}
openModal(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);
openModal(GithubUpdateDialog, {
onConfirm: () => closeAllModals()
});
}
});
}
</script>
<SettingsCard collapsible={false}>
<Github slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
<span slot="title">Github Firmware Manager</span>
{#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($page.data.features.firmware_version, 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($page.data.features.firmware_version, release.tag_name) != 0}
<button
class="btn btn-ghost btn-circle btn-sm"
on:click={() => {
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 flex-shrink-0" />
<span>Please connect to a network with internet access to perform a firmware update.</span>
</div>
{/await}
</SettingsCard>
@@ -0,0 +1,65 @@
<script lang="ts">
import { openModal, closeModal } from 'svelte-modals';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import OTA from '~icons/tabler/file-upload';
import Warning from '~icons/tabler/alert-triangle';
import Cancel from '~icons/tabler/x';
let files: FileList;
async function uploadBIN() {
try {
const formData = new FormData();
formData.append('file', files[0]);
const response = await fetch('/rest/uploadFirmware', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic'
},
body: formData
});
const result = await response.json();
} catch (error) {
console.error('Error:', error);
}
}
function confirmBinUpload() {
openModal(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: () => {
closeModal();
uploadBIN();
}
});
}
</script>
<SettingsCard collapsible={false}>
<OTA slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end rounded-full" />
<span slot="title">Upload Firmware</span>
<div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 flex-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"
on:change={confirmBinUpload}
/>
</SettingsCard>
+229
View File
@@ -0,0 +1,229 @@
<script lang="ts">
import type { PageData } from './$types';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { openModal, closeModal } from 'svelte-modals';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { user } from '$lib/stores/user';
import type { userProfile } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications';
import InputPassword from '$lib/components/InputPassword.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EditUser from './EditUser.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import Delete from '~icons/tabler/trash';
import AddUser from '~icons/tabler/user-plus';
import Edit from '~icons/tabler/pencil';
import Admin from '~icons/tabler/key';
import Users from '~icons/tabler/users';
import Warning from '~icons/tabler/alert-triangle';
import Cancel from '~icons/tabler/x';
import Check from '~icons/tabler/check';
export let data: PageData;
type userSetting = {
username: string;
password: string;
admin: boolean;
};
type SecuritySettings = {
jwt_secret: string;
users: userSetting[];
};
let securitySettings: SecuritySettings;
async function getSecuritySettings() {
try {
const response = await fetch('/rest/securitySettings', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
securitySettings = await response.json();
} catch (error) {
console.error('Error:', error);
}
return;
}
async function postSecuritySettings(data: SecuritySettings) {
try {
const response = await fetch('/rest/securitySettings', {
method: 'POST',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
securitySettings = await response.json();
if (response.status == 200) {
if (await validateUser($user)) {
notifications.success('Security settings updated.', 3000);
}
} else {
notifications.error('User not authorized.', 3000);
}
} catch (error) {
console.error('Error:', error);
}
return;
}
async function validateUser(userdata: userProfile) {
try {
const response = await fetch('/rest/verifyAuthorization', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + userdata.bearer_token,
'Content-Type': 'application/json'
}
});
if (response.status !== 200) {
user.invalidate();
return false;
}
} catch (error) {
console.error('Error:', error);
}
return true;
}
function confirmDelete(index: number) {
openModal(ConfirmDialog, {
title: 'Confirm Delete User',
message:
'Are you sure you want to delete the user "' +
securitySettings.users[index].username +
'"?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Yes', icon: Check }
},
onConfirm: () => {
securitySettings.users.splice(index, 1);
securitySettings = securitySettings;
closeModal();
postSecuritySettings(securitySettings);
}
});
}
function handleEdit(index: number) {
openModal(EditUser, {
title: 'Edit User',
user: { ...securitySettings.users[index] }, // Shallow Copy
onSaveUser: (editedUser: userSetting) => {
securitySettings.users[index] = editedUser;
closeModal();
postSecuritySettings(securitySettings);
}
});
}
function handleNewUser() {
openModal(EditUser, {
title: 'Add User',
onSaveUser: (newUser: userSetting) => {
securitySettings.users = [...securitySettings.users, newUser];
closeModal();
postSecuritySettings(securitySettings);
}
});
//
}
</script>
{#if $user.admin}
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<SettingsCard collapsible={false}>
<Users slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Manage Users</span>
{#await getSecuritySettings()}
<Spinner />
{:then nothing}
<div class="relative w-full overflow-visible">
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
on:click={handleNewUser}
>
<AddUser class="h-6 w-6" /></button
>
<div class="overflow-x-auto" transition:slide|local={{ duration: 300, easing: cubicOut }}>
<table class="table w-full table-auto">
<thead>
<tr class="font-bold">
<th align="left">Username</th>
<th align="center">Admin</th>
<th align="right" class="pr-8">Edit</th>
</tr>
</thead>
<tbody>
{#each securitySettings.users as user, index}
<tr>
<td align="left">{user.username}</td>
<td align="center">
{#if user.admin}
<Admin class="text-secondary" />
{/if}
</td>
<td align="right">
<span class="my-auto inline-flex flex-row space-x-2">
<button
class="btn btn-ghost btn-circle btn-xs"
on:click={() => handleEdit(index)}
>
<Edit class="h-6 w-6" /></button
>
<button
class="btn btn-ghost btn-circle btn-xs"
on:click={() => confirmDelete(index)}
>
<Delete class="text-error h-6 w-6" />
</button>
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<div class="divider mb-0" />
<span class="pb-2 text-xl font-medium">Security Settings</span>
<div class="alert alert-warning shadow-lg">
<Warning class="h-6 w-6 flex-shrink-0" />
<span
>The JWT secret is used to sign authentication tokens. If you modify the JWT Secret, all
users will be signed out.</span
>
</div>
<label class="label" for="secret">
<span class="label-text text-md">JWT Secret</span>
</label>
<InputPassword bind:value={securitySettings.jwt_secret} id="secret" />
<div class="mt-6 flex justify-end">
<button class="btn btn-primary" on:click={() => postSecuritySettings(securitySettings)}
>Apply Settings</button
>
</div>
{/await}
</SettingsCard>
</div>
{:else}
{goto('/')}
{/if}
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'Users'
};
}) satisfies PageLoad;
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { closeModal } from 'svelte-modals';
import { fly } from 'svelte/transition';
import InputPassword from '$lib/components/InputPassword.svelte';
import Cancel from '~icons/tabler/x';
import Save from '~icons/tabler/device-floppy';
// provided by <Modals />
export let isOpen: boolean;
export let title: string;
export let onSaveUser: any; // Callback on Save
export let user = {
username: '',
password: '',
admin: false
};
let errorUsername = false;
let usernameEditable = false;
onMount(() => {
if (user.username == '') {
usernameEditable = true;
}
});
function handleSave() {
// Validate if username is within range
if (user.username.length < 3 || user.username.length > 32) {
errorUsername = true;
} else {
errorUsername = false;
// Callback on saving
onSaveUser(user);
}
}
</script>
{#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
transition:fly={{ y: 50 }}
on:introstart
on:outroend
>
<div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg md:w-[28rem]"
>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2" />
<form
class="form-control text-base-content mb-1 w-full"
on:submit|preventDefault={handleSave}
novalidate
>
<label class="label" for="username">
<span class="label-text text-md">Username</span>
</label>
<input
type="text"
min="3"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2"
bind:value={user.username}
id="username"
disabled={!usernameEditable}
/>
<label for="username" class="label"
><span class="label-text-alt text-error {errorUsername ? '' : 'hidden'}"
>Username must be between 3 and 32 characters long</span
></label
>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<InputPassword bind:value={user.password} id="pwd" />
<label class="label my-auto cursor-pointer justify-start gap-4">
<input type="checkbox" bind:checked={user.admin} class="checkbox checkbox-primary" />
<span class="">Is Admin?</span>
</label>
<div class="divider my-2" />
<div class="flex justify-end gap-2">
<button
class="btn btn-neutral text-neutral-content inline-flex items-center"
on:click={closeModal}
type="button"
>
<Cancel class="mr-2 h-5 w-5" /><span>Cancel</span></button
>
<button
class="btn btn-primary text-primary-content inline-flex items-center"
type="submit"><Save class="mr-2 h-5 w-5" /><span>Save</span></button
>
</div>
</form>
</div>
</div>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
import { goto } from '$app/navigation';
export const load = (async () => {
goto('/');
return;
}) satisfies PageLoad;
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import type { PageData } from './$types';
import Accesspoint from './Accesspoint.svelte';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<Accesspoint />
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'Access Point'
};
}) satisfies PageLoad;
+457
View File
@@ -0,0 +1,457 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import InputPassword from '$lib/components/InputPassword.svelte';
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 Collapsible from '$lib/components/Collapsible.svelte';
import AP from '~icons/tabler/access-point';
import MAC from '~icons/tabler/dna-2';
import Home from '~icons/tabler/home';
import Devices from '~icons/tabler/devices';
type ApStatus = {
status: number;
ip_address: string;
mac_address: string;
station_num: number;
};
type ApSettings = {
provision_mode: number;
ssid: string;
password: string;
channel: number;
ssid_hidden: boolean;
max_clients: number;
local_ip: string;
gateway_ip: string;
subnet_mask: string;
};
let apSettings: ApSettings;
let apStatus: ApStatus;
let formField: any;
async function getAPStatus() {
try {
const response = await fetch('/rest/apStatus', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
apStatus = await response.json();
} catch (error) {
console.error('Error:', error);
}
return apStatus;
}
async function getAPSettings() {
try {
const response = await fetch('/rest/apSettings', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
apSettings = await response.json();
} catch (error) {
console.error('Error:', error);
}
return apSettings;
}
const interval = setInterval(async () => {
getAPStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getAPSettings();
}
});
let provisionMode = [
{
id: 0,
text: `Always`
},
{
id: 1,
text: `When WiFi Disconnected`
},
{
id: 2,
text: `Never`
}
];
let apStatusDescription = [
{ bg_color: 'bg-success', text_color: 'text-success-content', description: 'Active' },
{ bg_color: 'bg-error', text_color: 'text-error-content', description: 'Inactive' },
{ bg_color: 'bg-warning', text_color: 'text-warning-content', description: 'Lingering' }
];
let formErrors = {
ssid: false,
channel: false,
max_clients: false,
local_ip: false,
gateway_ip: false,
subnet_mask: false
};
async function postAPSettings(data: ApSettings) {
try {
const response = await fetch('/rest/apSettings', {
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('Access Point settings updated.', 3000);
apSettings = await response.json();
} else {
notifications.error('User not authorized.', 3000);
}
} catch (error) {
console.error('Error:', error);
}
}
function handleSubmitAP() {
let valid = true;
// Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false;
formErrors.ssid = true;
} else {
formErrors.ssid = false;
}
// Validate Channel
let channel = Number(apSettings.channel);
if (1 > channel || channel > 13) {
valid = false;
formErrors.channel = true;
} else {
formErrors.channel = 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;
}
// 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}>
<AP slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">Access Point</span>
<div class="w-full overflow-x-auto">
{#await getAPStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {apStatusDescription[apStatus.status].bg_color}"
>
<AP class="h-auto w-full scale-75 {apStatusDescription[apStatus.status].text_color}" />
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{apStatusDescription[apStatus.status].description}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Home class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">IP Address</div>
<div class="text-sm opacity-75">
{apStatus.ip_address}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<MAC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">MAC Address</div>
<div class="text-sm opacity-75">
{apStatus.mac_address}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Devices class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">AP Clients</div>
<div class="text-sm opacity-75">
{apStatus.station_num}
</div>
</div>
</div>
</div>
{/await}
</div>
{#if !$page.data.features.security || $user.admin}
<div class="bg-base-200 shadow-lg 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
</div>
{#await getAPSettings()}
<Spinner />
{:then nothing}
<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"
on:submit|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>
<InputPassword 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>
{/await}
</div>
{/if}
</SettingsCard>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import type { PageData } from './$types';
import Wifi from './Wifi.svelte';
export let data: PageData;
</script>
<div
class="mx-0 my-1 flex flex-col space-y-4
sm:mx-8 sm:my-8"
>
<Wifi />
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async () => {
return {
title: 'WiFi Station'
};
}) satisfies PageLoad;
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { closeModal } from 'svelte-modals';
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import Network from '~icons/tabler/router';
import AP from '~icons/tabler/access-point';
import Cancel from '~icons/tabler/x';
import Reload from '~icons/tabler/reload';
import { onMount, onDestroy } from 'svelte';
import RssiIndicator from '$lib/components/RSSIIndicator.svelte';
// provided by <Modals />
export let isOpen: boolean;
export let storeNetwork: any;
const encryptionType = [
'Open',
'WEP',
'WPA PSK',
'WPA2 PSK',
'WPA WPA2 PSK',
'WPA2 Enterprise',
'WPA3 PSK',
'WPA2 WPA3 PSK',
'WAPI PSK'
];
type networkItem = {
rssi: number;
ssid: string;
bssid: string;
channel: number;
encryption_type: number;
};
let listOfNetworks: networkItem[] = [];
let scanActive = false;
let pollingId: number;
async function scanNetworks() {
scanActive = true;
const scan = await fetch('/rest/scanNetworks', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
if ((await pollingResults()) == false) {
pollingId = setInterval(() => pollingResults(), 1000);
}
return;
}
async function pollingResults() {
const response = await fetch('/rest/listNetworks', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
try {
const result = await response.json();
listOfNetworks = result.networks;
if (listOfNetworks.length) {
scanActive = false;
clearInterval(pollingId);
pollingId = 0;
return true;
} else {
scanActive = false;
return false;
}
} catch {
return false;
}
}
onMount(() => {
scanNetworks();
});
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 }}
on:introstart
on:outroend
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 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]"
on:click={() => {
storeNetwork(network.ssid);
}}
>
<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="flex-grow" />
<RssiIndicator
showDBm={true}
rssi_dbm={network.rssi}
class="text-base-content h-10 w-10"
/>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<div class="divider my-2" />
<div class="flex flex-wrap justify-end gap-2">
<button
class="btn btn-primary inline-flex flex-none items-center"
disabled={scanActive}
on:click={scanNetworks}><Reload class="mr-2 h-5 w-5" /><span>Scan again</span></button
>
<div class="flex-grow" />
<button
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
on:click={closeModal}><Cancel class="mr-2 h-5 w-5" /><span>Cancel</span></button
>
</div>
</div>
</div>
{/if}
+792
View File
@@ -0,0 +1,792 @@
<svelte:options immutable={true} />
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { openModal, closeModal } from 'svelte-modals';
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { user } from '$lib/stores/user';
import { page } from '$app/stores';
import { notifications } from '$lib/components/toasts/notifications';
import DragDropList, { VerticalDropZone, reorder, type DropEvent } from 'svelte-dnd-list';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import InputPassword from '$lib/components/InputPassword.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ScanNetworks from './Scan.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import AP from '~icons/tabler/access-point';
import Router from '~icons/tabler/router';
import MAC from '~icons/tabler/dna-2';
import Home from '~icons/tabler/home';
import WiFi from '~icons/tabler/wifi';
import SSID from '~icons/tabler/router';
import Down from '~icons/tabler/chevron-down';
import DNS from '~icons/tabler/address-book';
import Gateway from '~icons/tabler/torii';
import Subnet from '~icons/tabler/grid-dots';
import Channel from '~icons/tabler/antenna';
import Scan from '~icons/tabler/radar-2';
import Add from '~icons/tabler/circle-plus';
import Edit from '~icons/tabler/pencil';
import Delete from '~icons/tabler/trash';
import Cancel from '~icons/tabler/x';
import Check from '~icons/tabler/check';
import InfoDialog from '$lib/components/InfoDialog.svelte';
type WifiStatus = {
status: number;
local_ip: string;
mac_address: string;
rssi: number;
ssid: string;
bssid: string;
channel: number;
subnet_mask: string;
gateway_ip: string;
dns_ip_1: string;
dns_ip_2?: string;
};
type WifiSettings = {
hostname: string;
priority_RSSI: boolean;
wifi_networks: networkItem[];
};
type networkItem = {
ssid: string;
password: string;
static_ip_config: boolean;
local_ip?: string;
subnet_mask?: string;
gateway_ip?: string;
dns_ip_1?: string;
dns_ip_2?: string;
};
let networkEditable: networkItem = {
ssid: '',
password: '',
static_ip_config: false,
local_ip: undefined,
subnet_mask: undefined,
gateway_ip: undefined,
dns_ip_1: undefined,
dns_ip_2: undefined
};
let newNetwork: boolean = true;
let showNetworkEditor: boolean = false;
let wifiStatus: WifiStatus;
let wifiSettings: WifiSettings;
let dndNetworkList: networkItem[] = [];
let showWifiDetails = false;
let formField: any;
let formErrors = {
ssid: false,
local_ip: false,
gateway_ip: false,
subnet_mask: false,
dns_1: false,
dns_2: false
};
let formErrorhostname = false;
async function getWifiStatus() {
try {
const response = await fetch('/rest/wifiStatus', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
wifiStatus = await response.json();
} catch (error) {
console.error('Error:', error);
}
return wifiStatus;
}
async function getWifiSettings() {
try {
const response = await fetch('/rest/wifiSettings', {
method: 'GET',
headers: {
Authorization: $page.data.features.security ? 'Bearer ' + $user.bearer_token : 'Basic',
'Content-Type': 'application/json'
}
});
wifiSettings = await response.json();
} catch (error) {
console.error('Error:', error);
}
dndNetworkList = wifiSettings.wifi_networks;
return wifiSettings;
}
const interval = setInterval(async () => {
getWifiStatus();
}, 5000);
onDestroy(() => clearInterval(interval));
onMount(() => {
if (!$page.data.features.security || $user.admin) {
getWifiSettings();
}
});
async function postWiFiSettings(data: WifiSettings) {
try {
const response = await fetch('/rest/wifiSettings', {
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('Wi-Fi settings updated.', 3000);
wifiSettings = await response.json();
} else {
notifications.error('User not authorized.', 3000);
}
} catch (error) {
console.error('Error:', error);
}
}
function validateHostName() {
if (wifiSettings.hostname.length < 3 || wifiSettings.hostname.length > 32) {
formErrorhostname = true;
} else {
formErrorhostname = false;
// Update global wifiSettings object
wifiSettings.wifi_networks = dndNetworkList;
// Post to REST API
postWiFiSettings(wifiSettings);
console.log(wifiSettings);
}
}
function validateWiFiForm() {
let valid = true;
// Validate SSID
if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) {
valid = false;
formErrors.ssid = true;
} else {
formErrors.ssid = false;
}
if (networkEditable.static_ip_config) {
// 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(networkEditable.gateway_ip!)) {
valid = false;
formErrors.gateway_ip = true;
} else {
formErrors.gateway_ip = false;
}
// Validate Subnet Mask
if (!regexExp.test(networkEditable.subnet_mask!)) {
valid = false;
formErrors.subnet_mask = true;
} else {
formErrors.subnet_mask = false;
}
// Validate local IP
if (!regexExp.test(networkEditable.local_ip!)) {
valid = false;
formErrors.local_ip = true;
} else {
formErrors.local_ip = false;
}
// Validate DNS 1
if (!regexExp.test(networkEditable.dns_ip_1!)) {
valid = false;
formErrors.dns_1 = true;
} else {
formErrors.dns_1 = false;
}
// Validate DNS 2
if (!regexExp.test(networkEditable.dns_ip_2!)) {
valid = false;
formErrors.dns_2 = true;
} else {
formErrors.dns_2 = false;
}
} else {
formErrors.local_ip = false;
formErrors.subnet_mask = false;
formErrors.gateway_ip = false;
formErrors.dns_1 = false;
formErrors.dns_2 = false;
}
// Submit JSON to REST API
if (valid) {
if (newNetwork) {
dndNetworkList.push(networkEditable);
} else {
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable);
}
addNetwork();
dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false;
}
}
function scanForNetworks() {
openModal(ScanNetworks, {
storeNetwork: (network: string) => {
addNetwork();
networkEditable.ssid = network;
showNetworkEditor = true;
closeModal();
}
});
}
function addNetwork() {
newNetwork = true;
networkEditable = {
ssid: '',
password: '',
static_ip_config: false,
local_ip: undefined,
subnet_mask: undefined,
gateway_ip: undefined,
dns_ip_1: undefined,
dns_ip_2: undefined
};
}
function handleEdit(index: number) {
newNetwork = false;
showNetworkEditor = true;
networkEditable = dndNetworkList[index];
}
function confirmDelete(index: number) {
openModal(ConfirmDialog, {
title: 'Delete Network',
message: 'Are you sure you want to delete this network?',
labels: {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Delete', icon: Delete }
},
onConfirm: () => {
// Check if network is currently been edited and delete as well
if (dndNetworkList[index].ssid === networkEditable.ssid) {
addNetwork();
}
// Remove network from array
dndNetworkList.splice(index, 1);
dndNetworkList = [...dndNetworkList]; //Trigger reactivity
showNetworkEditor = false;
closeModal();
}
});
}
function checkNetworkList() {
if (dndNetworkList.length >= 5) {
openModal(InfoDialog, {
title: 'Reached Maximum Networks',
message:
'You have reached the maximum number of networks. Please delete one to add another.',
dismiss: { label: 'OK', icon: Check },
onDismiss: () => {
closeModal();
}
});
return false;
} else {
return true;
}
}
function onDrop({ detail: { from, to } }: CustomEvent<DropEvent>) {
if (!to || from === to) {
return;
}
dndNetworkList = reorder(dndNetworkList, from.index, to.index);
console.log(dndNetworkList);
}
</script>
<SettingsCard collapsible={false}>
<Router slot="icon" class="lex-shrink-0 mr-2 h-6 w-6 self-end" />
<span slot="title">WiFi Connection</span>
<div class="w-full overflow-x-auto">
{#await getWifiStatus()}
<Spinner />
{:then nothing}
<div
class="flex w-full flex-col space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div
class="mask mask-hexagon h-auto w-10 {wifiStatus.status === 3
? 'bg-success'
: 'bg-error'}"
>
<AP
class="h-auto w-full scale-75 {wifiStatus.status === 3
? 'text-success-content'
: 'text-error-content'}"
/>
</div>
<div>
<div class="font-bold">Status</div>
<div class="text-sm opacity-75">
{wifiStatus.status === 3 ? 'Connected' : 'Inactive'}
</div>
</div>
</div>
{#if wifiStatus.status === 3}
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<SSID class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">SSID</div>
<div class="text-sm opacity-75">
{wifiStatus.ssid}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Home class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">IP Address</div>
<div class="text-sm opacity-75">
{wifiStatus.local_ip}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<WiFi class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">RSSI</div>
<div class="text-sm opacity-75">
{wifiStatus.rssi} dBm
</div>
</div>
<div class="grow" />
<button
class="btn btn-circle btn-ghost btn-sm modal-button"
on:click={() => {
showWifiDetails = !showWifiDetails;
}}
>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {showWifiDetails
? 'rotate-180'
: ''}"
/>
</button>
</div>
{/if}
</div>
<!-- Folds open -->
{#if showWifiDetails}
<div
class="flex w-full flex-col space-y-1 pt-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<MAC class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">MAC Address</div>
<div class="text-sm opacity-75">
{wifiStatus.mac_address}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Channel class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Channel</div>
<div class="text-sm opacity-75">
{wifiStatus.channel}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Gateway class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Gateway IP</div>
<div class="text-sm opacity-75">
{wifiStatus.gateway_ip}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<Subnet class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">Subnet Mask</div>
<div class="text-sm opacity-75">
{wifiStatus.subnet_mask}
</div>
</div>
</div>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10">
<DNS class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">DNS</div>
<div class="text-sm opacity-75">
{wifiStatus.dns_ip_1}
</div>
</div>
</div>
</div>
{/if}
{/await}
</div>
<div class="bg-base-200 shadow-lg 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"
>
Saved Networks
</div>
{#await getWifiSettings()}
<Spinner />
{:then nothing}
<div class="relative w-full overflow-visible">
{#if !$page.data.features.security || $user.admin}
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-16"
on:click={() => {
if (checkNetworkList()) {
addNetwork();
showNetworkEditor = true;
}
}}
>
<Add class="h-6 w-6" /></button
>
<button
class="btn btn-primary text-primary-content btn-md absolute -top-14 right-0"
on:click={() => {
if (checkNetworkList()) {
scanForNetworks();
showNetworkEditor = true;
}
}}
>
<Scan class="h-6 w-6" /></button
>
{/if}
<div
class="overflow-x-auto space-y-1"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<DragDropList
id="networks"
type={VerticalDropZone}
itemSize={60}
itemCount={dndNetworkList.length}
on:drop={onDrop}
let:index
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2">
<div class="mask mask-hexagon bg-primary h-auto w-10 shrink-0">
<Router class="text-primary-content h-auto w-full scale-75" />
</div>
<div>
<div class="font-bold">{dndNetworkList[index].ssid}</div>
</div>
{#if !$page.data.features.security || $user.admin}
<div class="flex-grow" />
<div class="space-x-0 px-0 mx-0">
<button
class="btn btn-ghost btn-sm"
on:click={() => {
handleEdit(index);
}}
>
<Edit class="h-6 w-6" /></button
>
<button
class="btn btn-ghost btn-sm"
on:click={() => {
confirmDelete(index);
}}
>
<Delete class="text-error h-6 w-6" />
</button>
</div>
{/if}
</div>
</DragDropList>
</div>
</div>
{#if !$page.data.features.security || $user.admin}
<div class="divider mb-0" />
<div
class="flex flex-col gap-2 p-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<form
class=""
on:submit|preventDefault={validateWiFiForm}
novalidate
bind:this={formField}
>
<div class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2">
<div>
<label class="label" for="channel">
<span class="label-text text-md">Host Name</span>
</label>
<input
type="text"
min="1"
max="32"
class="input input-bordered invalid:border-error w-full invalid:border-2 {formErrorhostname
? 'border-error border-2'
: ''}"
bind:value={wifiSettings.hostname}
id="channel"
required
/>
<label class="label" for="channel">
<span class="label-text-alt text-error {formErrorhostname ? '' : 'hidden'}"
>Host name must be between 2 and 32 characters long</span
>
</label>
</div>
<label class="label inline-flex cursor-pointer content-end justify-start gap-4">
<input
type="checkbox"
bind:checked={wifiSettings.priority_RSSI}
class="checkbox checkbox-primary sm:-mb-5"
/>
<span class="sm:-mb-5">Connect to strongest WiFi</span>
</label>
</div>
{#if showNetworkEditor}
<div class="divider my-0" />
<div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div>
<label class="label" for="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={networkEditable.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 3 and 32 characters long</span
>
</label>
</div>
<div>
<label class="label" for="pwd">
<span class="label-text text-md">Password</span>
</label>
<InputPassword bind:value={networkEditable.password} id="pwd" />
</div>
<label
class="label inline-flex cursor-pointer content-end justify-start gap-4 mt-2 sm:mb-4"
>
<input
type="checkbox"
bind:checked={networkEditable.static_ip_config}
class="checkbox checkbox-primary sm:-mb-5"
/>
<span class="sm:-mb-5">Static IP Config?</span>
</label>
</div>
{#if networkEditable.static_ip_config}
<div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<div>
<label class="label" for="localIP">
<span class="label-text text-md">Local IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.local_ip
? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.local_ip}
id="localIP"
required
/>
<label class="label" for="localIP">
<span class="label-text-alt text-error {formErrors.local_ip ? '' : 'hidden'}"
>Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">Gateway IP</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.gateway_ip
? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.gateway_ip}
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={networkEditable.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>
<div>
<label class="label" for="gateway">
<span class="label-text text-md">DNS 1</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_1
? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_1}
id="gateway"
required
/>
<label class="label" for="gateway">
<span class="label-text-alt text-error {formErrors.dns_1 ? '' : 'hidden'}"
>Must be a valid IPv4 address</span
>
</label>
</div>
<div>
<label class="label" for="subnet">
<span class="label-text text-md">DNS 2</span>
</label>
<input
type="text"
class="input input-bordered w-full {formErrors.dns_2
? 'border-error border-2'
: ''}"
minlength="7"
maxlength="15"
size="15"
bind:value={networkEditable.dns_ip_2}
id="subnet"
required
/>
<label class="label" for="subnet">
<span class="label-text-alt text-error {formErrors.dns_2 ? '' : 'hidden'}"
>Must be a valid IPv4 address</span
>
</label>
</div>
</div>
{/if}
{/if}
<div class="divider mb-2 mt-0" />
<div class="mx-4 flex flex-wrap justify-end gap-2">
<button class="btn btn-primary" type="submit" disabled={!showNetworkEditor}
>{newNetwork ? 'Add Network' : 'Update Network'}</button
>
<button class="btn btn-primary" type="button" on:click={validateHostName}
>Apply Settings</button
>
</div>
</form>
</div>
{/if}
{/await}
</div>
</SettingsCard>
+5 -1
View File
@@ -4,5 +4,9 @@ export default {
theme: {
extend: {}
},
plugins: [require('daisyui')]
plugins: [require('daisyui')],
daisyui: {
themes: ['light', 'dark'],
darkTheme: 'dark'
}
};
+18 -1
View File
@@ -1,5 +1,5 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
import { defineConfig } from 'vite';
import Icons from 'unplugin-icons/vite';
import viteLittleFS from './vite-plugin-littlefs';
@@ -13,5 +13,22 @@ export default defineConfig({
],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
server: {
proxy: {
'/rest': {
target: 'http://192.168.0.130',
changeOrigin: true
},
'/events': {
target: 'http://192.168.0.130',
changeOrigin: true
},
'/ws': {
target: 'ws://192.168.0.130',
changeOrigin: true,
ws: true
}
}
}
});