Compare commits

...

15 Commits

Author SHA1 Message Date
Aveline 03ae02968e
Merge 0b83dfc230 into 80a8b9e9e3 2025-09-16 05:52:04 -05:00
Marc Brooks 80a8b9e9e3
feat: Adds IPv6 disabling feature (#803)
* Allow disabling IPv6

Simply ignores any IPv6 addresses in the lease and doesn't offer them to the RPC
Also fixed display issue for IPv6 link local address.
Fixes https://github.com/orgs/jetkvm/projects/7/views/1?pane=issue&itemId=122761546&issue=jetkvm%7Ckvm%7C685

* Don't listen on disabled addresses in mDNS or web server.

* We have to set the IPv4 and IPv6 modes on the server.
2025-09-16 12:44:56 +02:00
Aveline 1717549578
fix: goroutine leak issue of cloudBlink (#801)
* fix: goroutine leak issue of cloudBlink

* chore: add lock and allow context to be cancelled earlier
2025-09-12 18:30:35 +02:00
Aveline 37b1a8bf34
docs: update pprof section of DEVELOPMENT.md (#802) 2025-09-12 11:11:28 +02:00
Marc Brooks ca8b06f4cf
chore: enhance the gzip and cacheable handling of static files
Add SVG and ICO to cacheable files.
Emit robots.txt directly.
Recognize WOFF2 (font) files as assets (so the get the immutable treatment)
Pre-gzip the entire /static/ directory (not just /static/assets/) and include SVG, ICO, and HTML files
Ensure fonts.css is processed by vite/rollup so that the preload and css reference the same immutable files (which get long-cached with hashes)
Add CircularXXWeb-Black to the preload list as it is used in the hot-path.
Handle system-driven color-scheme changes from dark to light correctly.
2025-09-12 08:41:41 +02:00
Aveline 33e099f258
update netboot.xyz-multiarch.iso to 2.0.88 (#799)
* chore: update netboot.xyz-multiarch.iso to 2.0.88

* feat: add script to update netboot.xyz iso
2025-09-12 08:41:17 +02:00
Aveline ea068414dc
feat: validate ssh public key before saving (#794)
* feat: validate ssh public key before saving

* fix: TestValidSSHKeyTypes
2025-09-11 23:32:40 +02:00
Adam Shiervani 8d1a66806c
refactor(ui): Don't fetch KeybardAndMouse Icon on every re-render (#795) 2025-09-11 19:57:35 +02:00
Aveline 6202e3cafa
chore: serve pre-compressed static files (#793) 2025-09-11 19:17:15 +02:00
dependabot[bot] c866230711
build(deps-dev): bump vite (#788)
Bumps the npm_and_yarn group with 1 update in the /ui directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.4 to 7.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 12:07:52 +02:00
Marc Brooks c8dd84c6b7
fix/Jiggler settings not saving (#786)
Ensure the jiggler config loads the defaults so they can be saved.
Ensure the file.Sync occurs before acknowledging save.
Also fixup the old KeyboardLayout to use en-US not en_US
2025-09-09 14:48:49 +02:00
Adam Shiervani c98592a412
feat(ui): Enhance EDID settings with loading state (#691)
* feat(ui): Enhance EDID settings with loading state and Fieldset component

* fix(ui): Improve notifications and adjust styling in custom EDID component

* fix(ui): specify JsonRpcResponse type
2025-09-08 11:38:49 +02:00
Marc Brooks 8fbad0112e
fix(ui): Don't render a button in a button (#782)
Gets rid of warning at initial page load.
2025-09-08 11:06:08 +02:00
Claus Holst 8a90555fad
Update URL Mount entries for Ubuntu, Fedora and Arch Linux (#783) 2025-09-08 11:02:46 +02:00
Adam Shiervani a7db0e8408
Enhance connection stats sidebar (#748)
* feat: add Metric component for data visualization

* refactor: update ConnectionStatsSidebar to use Metric component for improved data visualization

* feat: add someIterable utility function and update Metric components for consistent metric handling

- Introduced `someIterable` function to check for the presence of a metric in an iterable.
- Updated `CustomTooltip` and `Metric` components to use `metric` instead of `stat` for improved clarity.
- Refactored `StatChart` to align with the new metric naming convention.

* refactor: rename variable for clarity in Metric component

* docs: add JSDoc comments to createChartArray function in Metric component for better documentation

* feat: do an actual avg reference calc

* feat: Dont collect stats without a video track

* refactor: rename variables for clarity
2025-09-08 10:59:36 +02:00
38 changed files with 1179 additions and 451 deletions

View File

@ -301,13 +301,14 @@ export JETKVM_PROXY_URL="ws://<IP>"
### Performance Profiling ### Performance Profiling
```bash 1. Enable `Developer Mode` on your JetKVM device
# Enable profiling 2. Add a password on the `Access` tab
go build -o bin/jetkvm_app -ldflags="-X main.enableProfiling=true" cmd/main.go
```bash
# Access profiling # Access profiling
curl http://<IP>:6060/debug/pprof/ curl http://api:$JETKVM_PASSWORD@YOUR_DEVICE_IP/developer/pprof/
``` ```
### Advanced Environment Variables ### Advanced Environment Variables
```bash ```bash

View File

@ -62,7 +62,22 @@ build_dev_test: build_test2json build_gotestsum
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests . tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
frontend: frontend:
cd ui && npm ci && npm run build:device cd ui && npm ci && npm run build:device && \
find ../static/ \
-type f \
\( -name '*.js' \
-o -name '*.css' \
-o -name '*.html' \
-o -name '*.ico' \
-o -name '*.png' \
-o -name '*.jpg' \
-o -name '*.jpeg' \
-o -name '*.gif' \
-o -name '*.svg' \
-o -name '*.webp' \
-o -name '*.woff2' \
\) \
-exec sh -c 'gzip -9 -kfv {}' \;
dev_release: frontend build_dev dev_release: frontend build_dev
@echo "Uploading release..." @echo "Uploading release..."

View File

@ -118,6 +118,7 @@ var defaultConfig = &Config{
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI // This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{ JigglerConfig: &JigglerConfig{
InactivityLimitSeconds: 60, InactivityLimitSeconds: 60,
@ -205,6 +206,15 @@ func LoadConfig() {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
} }
if loadedConfig.JigglerConfig == nil {
loadedConfig.JigglerConfig = defaultConfig.JigglerConfig
}
// fixup old keyboard layout value
if loadedConfig.KeyboardLayout == "en_US" {
loadedConfig.KeyboardLayout = "en-US"
}
config = &loadedConfig config = &loadedConfig
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
@ -221,6 +231,11 @@ func SaveConfig() error {
logger.Trace().Str("path", configPath).Msg("Saving config") logger.Trace().Str("path", configPath).Msg("Saving config")
// fixup old keyboard layout value
if config.KeyboardLayout == "en_US" {
config.KeyboardLayout = "en-US"
}
file, err := os.Create(configPath) file, err := os.Create(configPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create config file: %w", err) return fmt.Errorf("failed to create config file: %w", err)
@ -233,6 +248,11 @@ func SaveConfig() error {
return fmt.Errorf("failed to encode config: %w", err) return fmt.Errorf("failed to encode config: %w", err)
} }
if err := file.Sync(); err != nil {
return fmt.Errorf("failed to wite config: %w", err)
}
logger.Info().Str("path", configPath).Msg("config saved")
return nil return nil
} }

View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@ -110,12 +111,6 @@ func clearDisplayState() {
currentScreen = "ui_Boot_Screen" currentScreen = "ui_Boot_Screen"
} }
var (
cloudBlinkLock sync.Mutex = sync.Mutex{}
cloudBlinkStopped bool
cloudBlinkTicker *time.Ticker
)
func updateDisplay() { func updateDisplay() {
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String())
if usbState == "configured" { if usbState == "configured" {
@ -152,48 +147,81 @@ func updateDisplay() {
stopCloudBlink() stopCloudBlink()
case CloudConnectionStateConnecting: case CloudConnectionStateConnecting:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
startCloudBlink() restartCloudBlink()
case CloudConnectionStateConnected: case CloudConnectionStateConnected:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
stopCloudBlink() stopCloudBlink()
} }
} }
func startCloudBlink() { const (
if cloudBlinkTicker == nil { cloudBlinkInterval = 2 * time.Second
cloudBlinkTicker = time.NewTicker(2 * time.Second) cloudBlinkDuration = 1 * time.Second
} else { )
// do nothing if the blink isn't stopped
if cloudBlinkStopped {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = false var (
cloudBlinkTicker.Reset(2 * time.Second) cloudBlinkTicker *time.Ticker
} cloudBlinkCancel context.CancelFunc
} cloudBlinkLock = sync.Mutex{}
)
go func() { func doCloudBlink(ctx context.Context) {
for range cloudBlinkTicker.C { for range cloudBlinkTicker.C {
if cloudConnectionState != CloudConnectionStateConnecting { if cloudConnectionState != CloudConnectionStateConnecting {
continue continue
} }
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond) _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds()))
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond) select {
case <-ctx.Done():
return
case <-time.After(cloudBlinkDuration):
} }
}()
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds()))
select {
case <-ctx.Done():
return
case <-time.After(cloudBlinkDuration):
}
}
}
func restartCloudBlink() {
stopCloudBlink()
startCloudBlink()
}
func startCloudBlink() {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
if cloudBlinkTicker == nil {
cloudBlinkTicker = time.NewTicker(cloudBlinkInterval)
} else {
cloudBlinkTicker.Reset(cloudBlinkInterval)
}
ctx, cancel := context.WithCancel(context.Background())
cloudBlinkCancel = cancel
go doCloudBlink(ctx)
} }
func stopCloudBlink() { func stopCloudBlink() {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
if cloudBlinkCancel != nil {
cloudBlinkCancel()
cloudBlinkCancel = nil
}
if cloudBlinkTicker != nil { if cloudBlinkTicker != nil {
cloudBlinkTicker.Stop() cloudBlinkTicker.Stop()
} }
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = true
} }
var ( var (

1
go.mod
View File

@ -81,6 +81,7 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vearutop/statigz v1.5.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.0 // indirect golang.org/x/arch v0.18.0 // indirect

2
go.sum
View File

@ -171,6 +171,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=

View File

@ -56,13 +56,12 @@ type NetworkConfig struct {
} }
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
mode := c.MDNSMode.String
listenOptions := &mdns.MDNSListenOptions{ listenOptions := &mdns.MDNSListenOptions{
IPv4: true, IPv4: c.IPv4Mode.String != "disabled",
IPv6: true, IPv6: c.IPv6Mode.String != "disabled",
} }
switch mode { switch c.MDNSMode.String {
case "ipv4_only": case "ipv4_only":
listenOptions.IPv6 = false listenOptions.IPv6 = false
case "ipv6_only": case "ipv6_only":

View File

@ -239,6 +239,10 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
ipv4Addresses = append(ipv4Addresses, addr.IP) ipv4Addresses = append(ipv4Addresses, addr.IP)
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
} else if addr.IP.To16() != nil { } else if addr.IP.To16() != nil {
if s.config.IPv6Mode.String == "disabled" {
continue
}
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
// check if it's a link local address // check if it's a link local address
if addr.IP.IsLinkLocalUnicast() { if addr.IP.IsLinkLocalUnicast() {
@ -287,6 +291,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
} }
s.ipv4Addresses = ipv4AddressesString s.ipv4Addresses = ipv4AddressesString
if s.config.IPv6Mode.String != "disabled" {
if ipv6LinkLocal != nil { if ipv6LinkLocal != nil {
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
@ -318,6 +323,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
changed = true changed = true
} }
} }
}
// if it's the initial check, we'll set changed to false // if it's the initial check, we'll set changed to false
initialCheck := !s.checked initialCheck := !s.checked

View File

@ -65,7 +65,7 @@ func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
ipv6Addresses := make([]RpcIPv6Address, 0) ipv6Addresses := make([]RpcIPv6Address, 0)
if s.ipv6Addresses != nil { if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
for _, addr := range s.ipv6Addresses { for _, addr := range s.ipv6Addresses {
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
Address: addr.Prefix.String(), Address: addr.Prefix.String(),

71
internal/utils/ssh.go Normal file
View File

@ -0,0 +1,71 @@
package utils
import (
"fmt"
"slices"
"strings"
"golang.org/x/crypto/ssh"
)
// ValidSSHKeyTypes is a list of valid SSH key types
//
// Please make sure that all the types in this list are supported by dropbear
// https://github.com/mkj/dropbear/blob/003c5fcaabc114430d5d14142e95ffdbbd2d19b6/src/signkey.c#L37
//
// ssh-dss is not allowed here as it's insecure
var ValidSSHKeyTypes = []string{
ssh.KeyAlgoRSA,
ssh.KeyAlgoED25519,
ssh.KeyAlgoECDSA256,
ssh.KeyAlgoECDSA384,
ssh.KeyAlgoECDSA521,
}
// ValidateSSHKey validates authorized_keys file content
func ValidateSSHKey(sshKey string) error {
// validate SSH key
var (
hasValidPublicKey = false
lastError = fmt.Errorf("no valid SSH key found")
)
for _, key := range strings.Split(sshKey, "\n") {
key = strings.TrimSpace(key)
// skip empty lines and comments
if key == "" || strings.HasPrefix(key, "#") {
continue
}
parsedPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
lastError = err
continue
}
if parsedPublicKey == nil {
continue
}
parsedType := parsedPublicKey.Type()
textType := strings.Fields(key)[0]
if parsedType != textType {
lastError = fmt.Errorf("parsed SSH key type %s does not match type in text %s", parsedType, textType)
continue
}
if !slices.Contains(ValidSSHKeyTypes, parsedType) {
lastError = fmt.Errorf("invalid SSH key type: %s", parsedType)
continue
}
hasValidPublicKey = true
}
if !hasValidPublicKey {
return lastError
}
return nil
}

208
internal/utils/ssh_test.go Normal file
View File

@ -0,0 +1,208 @@
package utils
import (
"strings"
"testing"
)
func TestValidateSSHKey(t *testing.T) {
tests := []struct {
name string
sshKey string
expectError bool
errorMsg string
}{
{
name: "valid RSA key",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
expectError: false,
},
{
name: "valid ED25519 key",
sshKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
expectError: false,
},
{
name: "valid ECDSA key",
sshKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAlTkxIo4mXBR+gEX0Q74BpYX4bFFHoX+8Uz7tsob8HvsnMvsEE+BW9h9XrbWX4/4ppL/o6sHbvsqNr9HcyKfdc= test@example.com",
expectError: false,
},
{
name: "multiple valid keys",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
expectError: false,
},
{
name: "valid key with comment",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
expectError: false,
},
{
name: "valid key with options and comment (we don't support options yet)",
sshKey: "command=\"echo hello\" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
expectError: true,
},
{
name: "empty string",
sshKey: "",
expectError: true,
errorMsg: "no valid SSH key found",
},
{
name: "whitespace only",
sshKey: " \n\t \n ",
expectError: true,
errorMsg: "no valid SSH key found",
},
{
name: "comment only",
sshKey: "# This is a comment\n# Another comment",
expectError: true,
errorMsg: "no valid SSH key found",
},
{
name: "invalid key format",
sshKey: "not-a-valid-ssh-key",
expectError: true,
},
{
name: "invalid key type",
sshKey: "ssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
expectError: true,
errorMsg: "invalid SSH key type: ssh-dss",
},
{
name: "unsupported key type",
sshKey: "ssh-rsa-cert-v01@openssh.com AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA...",
expectError: true,
errorMsg: "invalid SSH key type: ssh-rsa-cert-v01@openssh.com",
},
{
name: "malformed key data",
sshKey: "ssh-rsa invalid-base64-data",
expectError: true,
},
{
name: "type mismatch",
sshKey: "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIGomKoH...",
expectError: true,
errorMsg: "parsed SSH key type ssh-ed25519 does not match type in text ssh-rsa",
},
{
name: "mixed valid and invalid keys",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\ninvalid-key\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
expectError: false,
},
{
name: "valid key with empty lines and comments",
sshKey: "# Comment line\n\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\n# Another comment\n\t\n",
expectError: false,
},
{
name: "all invalid keys",
sshKey: "invalid-key-1\ninvalid-key-2\nssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSSHKey(tt.sshKey)
if tt.expectError {
if err == nil {
t.Errorf("ValidateSSHKey() expected error but got none")
} else if tt.errorMsg != "" && !strings.ContainsAny(err.Error(), tt.errorMsg) {
t.Errorf("ValidateSSHKey() error = %v, expected to contain %v", err, tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
}
}
})
}
}
func TestValidSSHKeyTypes(t *testing.T) {
expectedTypes := []string{
"ssh-rsa",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
}
if len(ValidSSHKeyTypes) != len(expectedTypes) {
t.Errorf("ValidSSHKeyTypes length = %d, expected %d", len(ValidSSHKeyTypes), len(expectedTypes))
}
for _, expectedType := range expectedTypes {
found := false
for _, actualType := range ValidSSHKeyTypes {
if actualType == expectedType {
found = true
break
}
}
if !found {
t.Errorf("ValidSSHKeyTypes missing expected type: %s", expectedType)
}
}
}
// TestValidateSSHKeyEdgeCases tests edge cases and boundary conditions
func TestValidateSSHKeyEdgeCases(t *testing.T) {
tests := []struct {
name string
sshKey string
expectError bool
}{
{
name: "key with only type",
sshKey: "ssh-rsa",
expectError: true,
},
{
name: "key with type and empty data",
sshKey: "ssh-rsa ",
expectError: true,
},
{
name: "key with type and whitespace data",
sshKey: "ssh-rsa \t ",
expectError: true,
},
{
name: "key with multiple spaces between type and data",
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
expectError: false,
},
{
name: "key with tabs",
sshKey: "\tssh-rsa\tAAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
expectError: false,
},
{
name: "very long line",
sshKey: "ssh-rsa " + string(make([]byte, 10000)),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSSHKey(tt.sshKey)
if tt.expectError {
if err == nil {
t.Errorf("ValidateSSHKey() expected error but got none")
}
} else {
if err != nil {
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
}
}
})
}
}

View File

@ -17,16 +17,20 @@ type JigglerConfig struct {
Timezone string `json:"timezone,omitempty"` Timezone string `json:"timezone,omitempty"`
} }
var jigglerEnabled = false
var jobDelta time.Duration = 0 var jobDelta time.Duration = 0
var scheduler gocron.Scheduler = nil var scheduler gocron.Scheduler = nil
func rpcSetJigglerState(enabled bool) { func rpcSetJigglerState(enabled bool) error {
jigglerEnabled = enabled config.JigglerEnabled = enabled
err := SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
} }
func rpcGetJigglerState() bool { func rpcGetJigglerState() bool {
return jigglerEnabled return config.JigglerEnabled
} }
func rpcGetTimezones() []string { func rpcGetTimezones() []string {
@ -118,7 +122,7 @@ func runJigglerCronTab() error {
} }
func runJiggler() { func runJiggler() {
if jigglerEnabled { if config.JigglerEnabled {
if config.JigglerConfig.JitterPercentage != 0 { if config.JigglerConfig.JitterPercentage != 0 {
jitter := calculateJitterDuration(jobDelta) jitter := calculateJitterDuration(jobDelta)
time.Sleep(jitter) time.Sleep(jitter)

View File

@ -20,6 +20,7 @@ import (
"github.com/jetkvm/kvm/internal/hidrpc" "github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils"
) )
type JSONRPCRequest struct { type JSONRPCRequest struct {
@ -432,7 +433,19 @@ func rpcGetSSHKeyState() (string, error) {
} }
func rpcSetSSHKeyState(sshKey string) error { func rpcSetSSHKeyState(sshKey string) error {
if sshKey != "" { if sshKey == "" {
// Remove SSH key file if empty string is provided
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove SSH key file: %w", err)
}
return nil
}
// Validate SSH key
if err := utils.ValidateSSHKey(sshKey); err != nil {
return err
}
// Create directory if it doesn't exist // Create directory if it doesn't exist
if err := os.MkdirAll(sshKeyDir, 0700); err != nil { if err := os.MkdirAll(sshKeyDir, 0700); err != nil {
return fmt.Errorf("failed to create SSH key directory: %w", err) return fmt.Errorf("failed to create SSH key directory: %w", err)
@ -442,12 +455,6 @@ func rpcSetSSHKeyState(sshKey string) error {
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil { if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
return fmt.Errorf("failed to write SSH key: %w", err) return fmt.Errorf("failed to write SSH key: %w", err)
} }
} else {
// Remove SSH key file if empty string is provided
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove SSH key file: %w", err)
}
}
return nil return nil
} }

View File

@ -13,10 +13,7 @@ func initMdns() error {
networkState.GetHostname(), networkState.GetHostname(),
networkState.GetFQDN(), networkState.GetFQDN(),
}, },
ListenOptions: &mdns.MDNSListenOptions{ ListenOptions: config.NetworkConfig.GetMDNSMode(),
IPv4: true,
IPv6: true,
},
}) })
if err != nil { if err != nil {
return err return err

Binary file not shown.

View File

@ -1 +1 @@
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521 01db2bbcd0bad46c3e21eb3cc5687d15df2153c3d8e2d4665b37acb55f0b5a57

Binary file not shown.

77
scripts/update_netboot_xyz.sh Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
C_RST="$(tput sgr0)"
C_ERR="$(tput setaf 1)"
C_OK="$(tput setaf 2)"
C_WARN="$(tput setaf 3)"
C_INFO="$(tput setaf 5)"
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
msg_info() { msg "$1" $C_INFO; }
msg_ok() { msg "$1" $C_OK; }
msg_err() { msg "$1" $C_ERR; }
msg_warn() { msg "$1" $C_WARN; }
# Get the latest release information
msg_info "Getting latest release information ..."
LATEST_RELEASE=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/netbootxyz/netboot.xyz/releases | jq '
[.[] | select(.prerelease == false and .draft == false and .assets != null and (.assets | length > 0))] |
sort_by(.created_at) |
.[-1]')
# Extract version, download URL, and digest
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
ISO_URL=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .browser_download_url')
EXPECTED_CHECKSUM=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .digest' | sed 's/sha256://')
msg_ok "Latest version: $VERSION"
msg_ok "ISO URL: $ISO_URL"
msg_ok "Expected SHA256: $EXPECTED_CHECKSUM"
# Check if we already have the same version
if [ -f "resource/netboot.xyz-multiarch.iso" ]; then
msg_info "Checking current resource file ..."
# First check by checksum (fastest)
CURRENT_CHECKSUM=$(shasum -a 256 resource/netboot.xyz-multiarch.iso | awk '{print $1}')
if [ "$CURRENT_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then
msg_ok "Resource file is already up to date (version $VERSION). No update needed."
exit 0
else
msg_info "Checksums differ, proceeding with download ..."
fi
fi
# Download ISO file
TMP_ISO=$(mktemp -t netbootxyziso)
msg_info "Downloading ISO file ..."
curl -L -o "$TMP_ISO" "$ISO_URL"
# Verify SHA256 checksum
msg_info "Verifying SHA256 checksum ..."
ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_ISO" | awk '{print $1}')
if [ "$EXPECTED_CHECKSUM" = "$ACTUAL_CHECKSUM" ]; then
msg_ok "Verified SHA256 checksum."
mv -f "$TMP_ISO" "resource/netboot.xyz-multiarch.iso"
msg_ok "Updated ISO file."
git add "resource/netboot.xyz-multiarch.iso"
git commit -m "chore: update netboot.xyz-multiarch.iso to $VERSION"
msg_ok "Committed changes."
msg_ok "You can now push the changes to the remote repository."
exit 0
else
msg_err "Inconsistent SHA256 checksum."
msg_err "Expected: $EXPECTED_CHECKSUM"
msg_err "Actual: $ACTUAL_CHECKSUM"
exit 1
fi

View File

@ -6,27 +6,34 @@
<!-- These are the fonts used in the app --> <!-- These are the fonts used in the app -->
<link <link
rel="preload" rel="preload"
href="/fonts/CircularXXWeb-Medium.woff2" href="./public/fonts/CircularXXWeb-Medium.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<link <link
rel="preload" rel="preload"
href="/fonts/CircularXXWeb-Book.woff2" href="./public/fonts/CircularXXWeb-Book.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<link <link
rel="preload" rel="preload"
href="/fonts/CircularXXWeb-Regular.woff2" href="./public/fonts/CircularXXWeb-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="./public/fonts/CircularXXWeb-Black.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="./public/fonts/fonts.css" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
@ -36,23 +43,21 @@
<meta name="theme-color" content="#051946" /> <meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." /> <meta name="description" content="A web-based KVM console for managing remote servers." />
<script> <script>
// Initial theme setup function applyThemeFromPreference() {
document.documentElement.classList.toggle( // dark theme setup
"dark", var darkDesired = localStorage.theme === "dark" ||
localStorage.theme === "dark" ||
(!("theme" in localStorage) && (!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches), window.matchMedia("(prefers-color-scheme: dark)").matches)
);
document.documentElement.classList.toggle("dark", darkDesired)
}
// initial theme application
applyThemeFromPreference();
// Listen for system theme changes // Listen for system theme changes
window window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
.matchMedia("(prefers-color-scheme: dark)") window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
.addEventListener("change", ({ matches }) => {
if (!("theme" in localStorage)) {
// Only auto-switch if user hasn't manually set a theme
document.documentElement.classList.toggle("dark", matches);
}
});
</script> </script>
</head> </head>
<body <body

80
ui/package-lock.json generated
View File

@ -68,7 +68,7 @@
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.12",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.4", "vite": "^7.1.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"engines": { "engines": {
@ -1793,6 +1793,66 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
@ -6563,13 +6623,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@ -6893,9 +6953,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.4", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
"integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
@ -6903,7 +6963,7 @@
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.43.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View File

@ -79,7 +79,7 @@
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.12",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.4", "vite": "^7.1.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -1,25 +1,25 @@
import Card from "@components/Card"; import Card from "@components/Card";
export interface CustomTooltipProps { export interface CustomTooltipProps {
payload: { payload: { date: number; stat: number }; unit: string }[]; payload: { payload: { date: number; metric: number }; unit: string }[];
} }
export default function CustomTooltip({ payload }: CustomTooltipProps) { export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) { if (payload?.length) {
const toolTipData = payload[0]; const toolTipData = payload[0];
const { date, stat } = toolTipData.payload; const { date, metric } = toolTipData.payload;
return ( return (
<Card> <Card>
<div className="p-2 text-black dark:text-white"> <div className="px-2 py-1.5 text-black dark:text-white">
<div className="font-semibold"> <div className="text-[13px] font-semibold">
{new Date(date * 1000).toLocaleTimeString()} {new Date(date * 1000).toLocaleTimeString()}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<div className="h-[2px] w-2 bg-blue-700" /> <div className="h-[2px] w-2 bg-blue-700" />
<span > <span className="text-[13px]">
{stat} {toolTipData?.unit} {metric} {toolTipData?.unit}
</span> </span>
</div> </div>
</div> </div>

View File

@ -103,7 +103,7 @@ export default function DashboardNavbar({
<hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" /> <hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
<div className="relative inline-block text-left"> <div className="relative inline-block text-left">
<Menu> <Menu>
<MenuButton className="h-full"> <MenuButton as="div" className="h-full">
<Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white"> <Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
{picture ? ( {picture ? (
<img <img

View File

@ -17,7 +17,7 @@ export default function Ipv6NetworkCard({
</h3> </h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2"> <div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.dhcp_lease?.ip && ( {networkState?.ipv6_link_local && (
<div className="flex flex-col justify-between"> <div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400"> <span className="text-sm text-slate-600 dark:text-slate-400">
Link-local Link-local

View File

@ -100,15 +100,12 @@ export default function KvmCard({
)} )}
</div> </div>
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton <MenuButton
as={Button} as={Button}
theme="light" theme="light"
TrailingIcon={LuEllipsisVertical} TrailingIcon={LuEllipsisVertical}
size="MD" size="MD"
></MenuButton> ></MenuButton>
</div>
<MenuItems <MenuItems
transition transition
className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in" className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in"

View File

@ -0,0 +1,180 @@
/* eslint-disable react-refresh/only-export-components */
import { ComponentProps } from "react";
import { cva, cx } from "cva";
import { someIterable } from "../utils";
import { GridCard } from "./Card";
import MetricsChart from "./MetricsChart";
interface ChartPoint {
date: number;
metric: number | null;
}
interface MetricProps<T, K extends keyof T> {
title: string;
description: string;
stream?: Map<number, T>;
metric?: K;
data?: ChartPoint[];
gate?: Map<number, unknown>;
supported?: boolean;
map?: (p: { date: number; metric: number | null }) => ChartPoint;
domain?: [number, number];
unit: string;
heightClassName?: string;
referenceValue?: number;
badge?: ComponentProps<typeof MetricHeader>["badge"];
badgeTheme?: ComponentProps<typeof MetricHeader>["badgeTheme"];
}
/**
* Creates a chart array from a metrics map and a metric name.
*
* @param metrics - Expected to be ordered from oldest to newest.
* @param metricName - Name of the metric to create a chart array for.
*/
export function createChartArray<T, K extends keyof T>(
metrics: Map<number, T>,
metricName: K,
) {
const result: { date: number; metric: number | null }[] = [];
const iter = metrics.entries();
let next = iter.next() as IteratorResult<[number, T]>;
const nowSeconds = Math.floor(Date.now() / 1000);
// We want 120 data points, in the chart.
const firstDate = Math.min(next.value?.[0] ?? nowSeconds, nowSeconds - 120);
for (let t = firstDate; t < nowSeconds; t++) {
while (!next.done && next.value[0] < t) next = iter.next();
const has = !next.done && next.value[0] === t;
let metric = null;
if (has) metric = next.value[1][metricName] as number;
result.push({ date: t, metric });
if (has) next = iter.next();
}
return result;
}
function computeReferenceValue(points: ChartPoint[]): number | undefined {
const values = points
.filter(p => p.metric != null && Number.isFinite(p.metric))
.map(p => Number(p.metric));
if (values.length === 0) return undefined;
const sum = values.reduce((acc, v) => acc + v, 0);
const mean = sum / values.length;
return Math.round(mean);
}
const theme = {
light:
"bg-white text-black border border-slate-800/20 dark:border dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300",
danger: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50",
primary: "bg-blue-500 dark:border-blue-700 dark:bg-blue-800 dark:text-blue-50",
};
interface SettingsItemProps {
readonly title: string;
readonly description: string | React.ReactNode;
readonly badge?: string;
readonly className?: string;
readonly children?: React.ReactNode;
readonly badgeTheme?: keyof typeof theme;
}
export function MetricHeader(props: SettingsItemProps) {
const { title, description, badge } = props;
const badgeVariants = cva({ variants: { theme: theme } });
return (
<div className="space-y-0.5">
<div className="flex items-center gap-x-2">
<div className="flex w-full items-center justify-between text-base font-semibold text-black dark:text-white">
{title}
{badge && (
<span
className={cx(
"ml-2 rounded-sm px-2 py-1 font-mono text-[10px] leading-none font-medium",
badgeVariants({ theme: props.badgeTheme ?? "light" }),
)}
>
{badge}
</span>
)}
</div>
</div>
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
</div>
);
}
export function Metric<T, K extends keyof T>({
title,
description,
stream,
metric,
data,
gate,
supported,
map,
domain = [0, 600],
unit = "",
heightClassName = "h-[127px]",
badge,
badgeTheme,
}: MetricProps<T, K>) {
const ready = gate ? gate.size > 0 : stream ? stream.size > 0 : true;
const supportedFinal =
supported ??
(stream && metric ? someIterable(stream, ([, s]) => s[metric] !== undefined) : true);
// Either we let the consumer provide their own chartArray, or we create one from the stream and metric.
const raw = data ?? ((stream && metric && createChartArray(stream, metric)) || []);
// If the consumer provides a map function, we apply it to the raw data.
const dataFinal: ChartPoint[] = map ? raw.map(map) : raw;
// Compute the average value of the metric.
const referenceValue = computeReferenceValue(dataFinal);
return (
<div className="space-y-2">
<MetricHeader
title={title}
description={description}
badge={badge}
badgeTheme={badgeTheme}
/>
<GridCard>
<div
className={`flex ${heightClassName} w-full items-center justify-center text-sm text-slate-500`}
>
{!ready ? (
<div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : supportedFinal ? (
<MetricsChart
data={dataFinal}
domain={domain}
unit={unit}
referenceValue={referenceValue}
/>
) : (
<div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p>
</div>
)}
</div>
</GridCard>
</div>
);
}

View File

@ -12,13 +12,13 @@ import {
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip"; import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({ export default function MetricsChart({
data, data,
domain, domain,
unit, unit,
referenceValue, referenceValue,
}: { }: {
data: { date: number; stat: number | null | undefined }[]; data: { date: number; metric: number | null | undefined }[];
domain?: [string | number, string | number]; domain?: [string | number, string | number];
unit?: string; unit?: string;
referenceValue?: number; referenceValue?: number;
@ -33,7 +33,7 @@ export default function StatChart({
strokeLinecap="butt" strokeLinecap="butt"
stroke="rgba(30, 41, 59, 0.1)" stroke="rgba(30, 41, 59, 0.1)"
/> />
{referenceValue && ( {referenceValue !== undefined && (
<ReferenceLine <ReferenceLine
y={referenceValue} y={referenceValue}
strokeDasharray="3 3" strokeDasharray="3 3"
@ -64,7 +64,7 @@ export default function StatChart({
.map(x => x.date)} .map(x => x.date)}
/> />
<YAxis <YAxis
dataKey="stat" dataKey="metric"
axisLine={false} axisLine={false}
orientation="right" orientation="right"
tick={{ tick={{
@ -73,6 +73,7 @@ export default function StatChart({
fill: "rgba(107, 114, 128, 1)", fill: "rgba(107, 114, 128, 1)",
}} }}
padding={{ top: 0, bottom: 0 }} padding={{ top: 0, bottom: 0 }}
allowDecimals
tickLine={false} tickLine={false}
unit={unit} unit={unit}
domain={domain || ["auto", "auto"]} domain={domain || ["auto", "auto"]}
@ -87,7 +88,7 @@ export default function StatChart({
<Line <Line
type="monotone" type="monotone"
isAnimationActive={false} isAnimationActive={false}
dataKey="stat" dataKey="metric"
stroke="rgb(29 78 216)" stroke="rgb(29 78 216)"
strokeLinecap="round" strokeLinecap="round"
strokeWidth={2} strokeWidth={2}

View File

@ -22,15 +22,7 @@ const USBStateMap: Record<USBStates, string> = {
"not attached": "Disconnected", "not attached": "Disconnected",
suspended: "Low power mode", suspended: "Low power mode",
}; };
const StatusCardProps: StatusProps = {
export default function USBStateStatus({
state,
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState | null;
}) {
const StatusCardProps: StatusProps = {
configured: { configured: {
icon: ({ className }) => ( icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" /> <img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
@ -62,7 +54,16 @@ export default function USBStateStatus({
iconClassName: "h-5 w-5 opacity-50 grayscale filter", iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600", statusIndicatorClassName: "bg-green-500 border-green-600",
}, },
}; };
export default function USBStateStatus({
state,
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState | null;
}) {
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.warn("Unsupported USB state: ", state); console.warn("Unsupported USB state: ", state);

View File

@ -1,74 +1,40 @@
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import SidebarHeader from "@/components/SidebarHeader"; import SidebarHeader from "@/components/SidebarHeader";
import { GridCard } from "@/components/Card";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@/components/StatChart"; import { someIterable } from "@/utils";
function createChartArray<T, K extends keyof T>( import { createChartArray, Metric } from "../Metric";
stream: Map<number, T>, import { SettingsSectionHeader } from "../SettingsSectionHeader";
metric: K,
): { date: number; stat: T[K] | null }[] {
const stat = Array.from(stream).map(([key, stats]) => {
return { date: key, stat: stats[metric] };
});
// Sort the dates to ensure they are in chronological order
const sortedStat = stat.map(x => x.date).sort((a, b) => a - b);
// Determine the earliest statistic date
const earliestStat = sortedStat[0];
// Current time in seconds since the Unix epoch
const now = Math.floor(Date.now() / 1000);
// Determine the starting point for the chart data
const firstChartDate = earliestStat ? Math.min(earliestStat, now - 120) : now - 120;
// Generate the chart array for the range between 'firstChartDate' and 'now'
return Array.from({ length: now - firstChartDate }, (_, i) => {
const currentDate = firstChartDate + i;
return {
date: currentDate,
// Find the statistic for 'currentDate', or use the last known statistic if none exists for that date
stat: stat.find(x => x.date === currentDate)?.stat ?? null,
};
});
}
export default function ConnectionStatsSidebar() { export default function ConnectionStatsSidebar() {
const { sidebarView, setSidebarView } = useUiStore(); const { sidebarView, setSidebarView } = useUiStore();
const { const {
mediaStream, mediaStream,
peerConnection, peerConnection,
inboundRtpStats, inboundRtpStats: inboundVideoRtpStats,
appendInboundRtpStats, appendInboundRtpStats: appendInboundVideoRtpStats,
candidatePairStats, candidatePairStats: iceCandidatePairStats,
appendCandidatePairStats, appendCandidatePairStats,
appendLocalCandidateStats, appendLocalCandidateStats,
appendRemoteCandidateStats, appendRemoteCandidateStats,
appendDiskDataChannelStats, appendDiskDataChannelStats,
} = useRTCStore(); } = useRTCStore();
function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>,
metric: K,
): boolean {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
}
useInterval(function collectWebRTCStats() { useInterval(function collectWebRTCStats() {
(async () => { (async () => {
if (!mediaStream) return; if (!mediaStream) return;
const videoTrack = mediaStream.getVideoTracks()[0]; const videoTrack = mediaStream.getVideoTracks()[0];
if (!videoTrack) return; if (!videoTrack) return;
const stats = await peerConnection?.getStats(); const stats = await peerConnection?.getStats();
let successfulLocalCandidateId: string | null = null; let successfulLocalCandidateId: string | null = null;
let successfulRemoteCandidateId: string | null = null; let successfulRemoteCandidateId: string | null = null;
stats?.forEach(report => { stats?.forEach(report => {
if (report.type === "inbound-rtp") { if (report.type === "inbound-rtp" && report.kind === "video") {
appendInboundRtpStats(report); appendInboundVideoRtpStats(report);
} else if (report.type === "candidate-pair" && report.nominated) { } else if (report.type === "candidate-pair" && report.nominated) {
if (report.state === "succeeded") { if (report.state === "succeeded") {
successfulLocalCandidateId = report.localCandidateId; successfulLocalCandidateId = report.localCandidateId;
@ -91,144 +57,133 @@ export default function ConnectionStatsSidebar() {
})(); })();
}, 500); }, 500);
const jitterBufferDelay = createChartArray(inboundVideoRtpStats, "jitterBufferDelay");
const jitterBufferEmittedCount = createChartArray(
inboundVideoRtpStats,
"jitterBufferEmittedCount",
);
const jitterBufferAvgDelayData = jitterBufferDelay.map((d, idx) => {
if (idx === 0) return { date: d.date, metric: null };
const prevDelay = jitterBufferDelay[idx - 1]?.metric as number | null | undefined;
const currDelay = d.metric as number | null | undefined;
const prevCountEmitted =
(jitterBufferEmittedCount[idx - 1]?.metric as number | null | undefined) ?? null;
const currCountEmitted =
(jitterBufferEmittedCount[idx]?.metric as number | null | undefined) ?? null;
if (
prevDelay == null ||
currDelay == null ||
prevCountEmitted == null ||
currCountEmitted == null
) {
return { date: d.date, metric: null };
}
const deltaDelay = currDelay - prevDelay;
const deltaEmitted = currCountEmitted - prevCountEmitted;
// Guard counter resets or no emitted frames
if (deltaDelay < 0 || deltaEmitted <= 0) {
return { date: d.date, metric: null };
}
const valueMs = Math.round((deltaDelay / deltaEmitted) * 1000);
return { date: d.date, metric: valueMs };
});
return ( return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs"> <div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900"> <div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">
{/*
The entire sidebar component is always rendered, with a display none when not visible
The charts below, need a height and width, otherwise they throw. So simply don't render them unless the thing is visible
*/}
{sidebarView === "connection-stats" && ( {sidebarView === "connection-stats" && (
<div className="space-y-4"> <div className="space-y-8">
<div className="space-y-2"> {/* Connection Group */}
<div> <div className="space-y-3">
<h2 className="text-lg font-semibold text-black dark:text-white"> <SettingsSectionHeader
Packets Lost title="Connection"
</h2> description="The connection between the client and the JetKVM."
<p className="text-sm text-slate-700 dark:text-slate-300">
Number of data packets lost during transmission.
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
<StatChart
data={createChartArray(inboundRtpStats, "packetsLost")}
domain={[0, 100]}
unit=" packets"
/> />
) : ( <Metric
<div className="flex flex-col items-center space-y-1"> title="Round-Trip Time"
<p className="text-black">Metric not supported</p> description="Round-trip time for the active ICE candidate pair between peers."
</div> stream={iceCandidatePairStats}
)} metric="currentRoundTripTime"
</div> map={x => ({
</GridCard>
</div>
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold text-black dark:text-white">
Round-Trip Time
</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
Time taken for data to travel from source to destination and back
</p>
</div>
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p>
</div>
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
<StatChart
data={createChartArray(
candidatePairStats,
"currentRoundTripTime",
).map(x => {
return {
date: x.date, date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null, metric: x.metric != null ? Math.round(x.metric * 1000) : null,
};
})} })}
domain={[0, 600]} domain={[0, 600]}
unit=" ms" unit=" ms"
/> />
) : (
<div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p>
</div> </div>
)}
</div> {/* Video Group */}
</GridCard> <div className="space-y-3">
</div> <SettingsSectionHeader
<div className="space-y-2"> title="Video"
<div> description="The video stream from the JetKVM to the client."
<h2 className="text-lg font-semibold text-black dark:text-white"> />
Jitter
</h2> {/* RTP Jitter */}
<p className="text-sm text-slate-700 dark:text-slate-300"> <Metric
Variation in packet delay, affecting video smoothness.{" "} title="Network Stability"
</p> badge="Jitter"
</div> badgeTheme="light"
<GridCard> description="How steady the flow of inbound video packets is across the network."
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> stream={inboundVideoRtpStats}
{inboundRtpStats.size === 0 ? ( metric="jitter"
<div className="flex flex-col items-center space-y-1"> map={x => ({
<p className="text-slate-700">Waiting for data...</p>
</div>
) : (
<StatChart
data={createChartArray(inboundRtpStats, "jitter").map(x => {
return {
date: x.date, date: x.date,
stat: x.stat ? Math.round(x.stat * 1000) : null, metric: x.metric != null ? Math.round(x.metric * 1000) : null,
};
})} })}
domain={[0, 300]} domain={[0, 10]}
unit=" ms" unit=" ms"
/> />
)}
</div> {/* Playback Delay */}
</GridCard> <Metric
</div> title="Playback Delay"
<div className="space-y-2"> description="Delay added by the jitter buffer to smooth playback when frames arrive unevenly."
<div> badge="Jitter Buffer Avg. Delay"
<h2 className="text-lg font-semibold text-black dark:text-white"> badgeTheme="light"
Frames per second data={jitterBufferAvgDelayData}
</h2> gate={inboundVideoRtpStats}
<p className="text-sm text-slate-700 dark:text-slate-300"> supported={
Number of video frames displayed per second. someIterable(
</p> inboundVideoRtpStats,
</div> ([, x]) => x.jitterBufferDelay != null,
<GridCard> ) &&
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> someIterable(
{inboundRtpStats.size === 0 ? ( inboundVideoRtpStats,
<div className="flex flex-col items-center space-y-1"> ([, x]) => x.jitterBufferEmittedCount != null,
<p className="text-slate-700">Waiting for data...</p> )
</div> }
) : ( domain={[0, 30]}
<StatChart unit=" ms"
data={createChartArray(inboundRtpStats, "framesPerSecond").map( />
x => {
return { {/* Packets Lost */}
date: x.date, <Metric
stat: x.stat ? x.stat : null, title="Packets Lost"
}; description="Count of lost inbound video RTP packets."
}, stream={inboundVideoRtpStats}
)} metric="packetsLost"
domain={[0, 100]}
unit=" packets"
/>
{/* Frames Per Second */}
<Metric
title="Frames per second"
description="Number of inbound video frames displayed per second."
stream={inboundVideoRtpStats}
metric="framesPerSecond"
domain={[0, 80]} domain={[0, 80]}
unit=" fps" unit=" fps"
/> />
)}
</div>
</GridCard>
</div> </div>
</div> </div>
)} )}

View File

@ -116,6 +116,7 @@ if (isOnDevice) {
path: "/", path: "/",
errorElement: <ErrorBoundary />, errorElement: <ErrorBoundary />,
element: <DeviceRoute />, element: <DeviceRoute />,
HydrateFallback: () => <div className="p-4">Loading...</div>,
loader: DeviceRoute.loader, loader: DeviceRoute.loader,
children: [ children: [
{ {

View File

@ -355,7 +355,7 @@ function UrlView({
const popularImages = [ const popularImages = [
{ {
name: "Ubuntu 24.04 LTS", name: "Ubuntu 24.04 LTS",
url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso", url: "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-desktop-amd64.iso",
icon: UbuntuIcon, icon: UbuntuIcon,
}, },
{ {
@ -369,8 +369,8 @@ function UrlView({
icon: DebianIcon, icon: DebianIcon,
}, },
{ {
name: "Fedora 41", name: "Fedora 42",
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso", url: "https://download.fedoraproject.org/pub/fedora/linux/releases/42/Workstation/x86_64/iso/Fedora-Workstation-Live-42-1.1.x86_64.iso",
icon: FedoraIcon, icon: FedoraIcon,
}, },
{ {
@ -385,7 +385,7 @@ function UrlView({
}, },
{ {
name: "Arch Linux", name: "Arch Linux",
url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso", url: "https://archlinux.doridian.net/iso/latest/archlinux-x86_64.iso",
icon: ArchIcon, icon: ArchIcon,
}, },
{ {

View File

@ -90,6 +90,7 @@ export default function SettingsMouseRoute() {
send("getJigglerState", {}, (resp: JsonRpcResponse) => { send("getJigglerState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
const isEnabled = resp.result as boolean; const isEnabled = resp.result as boolean;
console.log("Jiggler is enabled:", isEnabled);
// If the jiggler is disabled, set the selected option to "disabled" and nothing else // If the jiggler is disabled, set the selected option to "disabled" and nothing else
if (!isEnabled) return setSelectedJigglerOption("disabled"); if (!isEnabled) return setSelectedJigglerOption("disabled");

View File

@ -166,11 +166,11 @@ export default function SettingsNetworkRoute() {
}, [getNetworkState, getNetworkSettings]); }, [getNetworkState, getNetworkSettings]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => { const handleIpv4ModeChange = (value: IPv4Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode }); setNetworkSettingsRemote({ ...networkSettings, ipv4_mode: value as IPv4Mode });
}; };
const handleIpv6ModeChange = (value: IPv6Mode | string) => { const handleIpv6ModeChange = (value: IPv6Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv6_mode: value as IPv6Mode }); setNetworkSettingsRemote({ ...networkSettings, ipv6_mode: value as IPv6Mode });
}; };
const handleLldpModeChange = (value: LLDPMode | string) => { const handleLldpModeChange = (value: LLDPMode | string) => {
@ -419,7 +419,7 @@ export default function SettingsNetworkRoute() {
value={networkSettings.ipv6_mode} value={networkSettings.ipv6_mode}
onChange={e => handleIpv6ModeChange(e.target.value)} onChange={e => handleIpv6ModeChange(e.target.value)}
options={filterUnknown([ options={filterUnknown([
// { value: "disabled", label: "Disabled" }, { value: "disabled", label: "Disabled" },
{ value: "slaac", label: "SLAAC" }, { value: "slaac", label: "SLAAC" },
// { value: "dhcpv6", label: "DHCPv6" }, // { value: "dhcpv6", label: "DHCPv6" },
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },

View File

@ -1,15 +1,16 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea"; import { TextAreaWithLabel } from "@/components/TextArea";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import notifications from "../notifications"; import Fieldset from "@components/Fieldset";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import notifications from "@/notifications";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
const defaultEdid = const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [ const edids = [
@ -50,21 +51,27 @@ export default function SettingsVideoRoute() {
const [streamQuality, setStreamQuality] = useState("1"); const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null); const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null); const [edid, setEdid] = useState<string | null>(null);
const [edidLoading, setEdidLoading] = useState(false);
// Video enhancement settings from store // Video enhancement settings from store
const { const {
videoSaturation, setVideoSaturation, videoSaturation,
videoBrightness, setVideoBrightness, setVideoSaturation,
videoContrast, setVideoContrast videoBrightness,
setVideoBrightness,
videoContrast,
setVideoContrast,
} = useSettingsStore(); } = useSettingsStore();
useEffect(() => { useEffect(() => {
setEdidLoading(true);
send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => { send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
setStreamQuality(String(resp.result)); setStreamQuality(String(resp.result));
}); });
send("getEDID", {}, (resp: JsonRpcResponse) => { send("getEDID", {}, (resp: JsonRpcResponse) => {
setEdidLoading(false);
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`); notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return; return;
@ -89,7 +96,10 @@ export default function SettingsVideoRoute() {
}, [send]); }, [send]);
const handleStreamQualityChange = (factor: string) => { const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, (resp: JsonRpcResponse) => { send(
"setStreamQualityFactor",
{ factor: Number(factor) },
(resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`, `Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
@ -97,20 +107,25 @@ export default function SettingsVideoRoute() {
return; return;
} }
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`); notifications.success(
`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`,
);
setStreamQuality(factor); setStreamQuality(factor);
}); },
);
}; };
const handleEDIDChange = (newEdid: string) => { const handleEDIDChange = (newEdid: string) => {
setEdidLoading(true);
send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => { send("setEDID", { edid: newEdid }, (resp: JsonRpcResponse) => {
setEdidLoading(false);
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`); notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return; return;
} }
notifications.success( notifications.success(
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`, `EDID set successfully to ${edids.find(x => x.value === newEdid)?.label ?? "the custom EDID"}`,
); );
// Update the EDID value in the UI // Update the EDID value in the UI
setEdid(newEdid); setEdid(newEdid);
@ -158,7 +173,7 @@ export default function SettingsVideoRoute() {
step="0.1" step="0.1"
value={videoSaturation} value={videoSaturation}
onChange={e => setVideoSaturation(parseFloat(e.target.value))} onChange={e => setVideoSaturation(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/> />
</SettingsItem> </SettingsItem>
@ -173,7 +188,7 @@ export default function SettingsVideoRoute() {
step="0.1" step="0.1"
value={videoBrightness} value={videoBrightness}
onChange={e => setVideoBrightness(parseFloat(e.target.value))} onChange={e => setVideoBrightness(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/> />
</SettingsItem> </SettingsItem>
@ -188,7 +203,7 @@ export default function SettingsVideoRoute() {
step="0.1" step="0.1"
value={videoContrast} value={videoContrast}
onChange={e => setVideoContrast(parseFloat(e.target.value))} onChange={e => setVideoContrast(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" className="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/> />
</SettingsItem> </SettingsItem>
@ -205,10 +220,11 @@ export default function SettingsVideoRoute() {
/> />
</div> </div>
</div> </div>
<Fieldset disabled={edidLoading} className="space-y-2">
<SettingsItem <SettingsItem
title="EDID" title="EDID"
description="Adjust the EDID settings for the display" description="Adjust the EDID settings for the display"
loading={edidLoading}
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
@ -245,12 +261,14 @@ export default function SettingsVideoRoute() {
size="SM" size="SM"
theme="primary" theme="primary"
text="Set Custom EDID" text="Set Custom EDID"
loading={edidLoading}
onClick={() => handleEDIDChange(customEdidValue)} onClick={() => handleEDIDChange(customEdidValue)}
/> />
<Button <Button
size="SM" size="SM"
theme="light" theme="light"
text="Restore to default" text="Restore to default"
loading={edidLoading}
onClick={() => { onClick={() => {
setCustomEdidValue(null); setCustomEdidValue(null);
handleEDIDChange(defaultEdid); handleEDIDChange(defaultEdid);
@ -259,6 +277,7 @@ export default function SettingsVideoRoute() {
</div> </div>
</> </>
)} )}
</Fieldset>
</div> </div>
</div> </div>
</div> </div>

View File

@ -94,6 +94,17 @@ export const formatters = {
}, },
}; };
export function someIterable<T>(
iterable: Iterable<T>,
predicate: (item: T) => boolean,
): boolean {
for (const item of iterable) {
if (predicate(item)) return true;
}
return false;
}
export const VIDEO = new Blob( export const VIDEO = new Blob(
[ [
new Uint8Array([ new Uint8Array([

View File

@ -31,7 +31,23 @@ export default defineConfig(({ mode, command }) => {
esbuild: { esbuild: {
pure: ["console.debug"], pure: ["console.debug"],
}, },
build: { outDir: isCloud ? "dist" : "../static" }, assetsInclude: ["**/*.woff2"],
build: {
outDir: isCloud ? "dist" : "../static",
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes("node_modules")) {
return "vendor";
}
return null;
},
assetFileNames: "assets/immutable/[name]-[hash][extname]",
chunkFileNames: "assets/immutable/[name]-[hash].js",
entryFileNames: "assets/immutable/[name]-[hash].js",
},
},
},
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
https: useSSL, https: useSSL,

61
web.go
View File

@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"time" "time"
@ -24,6 +25,7 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/vearutop/statigz"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -66,6 +68,10 @@ type SetupRequest struct {
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
} }
var cachableFileExtensions = []string{
".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".ico", ".woff2",
}
func setupRouter() *gin.Engine { func setupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
gin.DisableConsoleColor() gin.DisableConsoleColor()
@ -75,23 +81,47 @@ func setupRouter() *gin.Engine {
return *ginLogger return *ginLogger
}), }),
)) ))
staticFS, _ := fs.Sub(staticFiles, "static")
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatal().Err(err).Msg("failed to get rooted static files subdirectory")
}
staticFileServer := http.StripPrefix("/static", statigz.FileServer(
staticFS.(fs.ReadDirFS),
))
// Add a custom middleware to set cache headers for images // Add a custom middleware to set cache headers for images
// This is crucial for optimizing the initial welcome screen load time // This is crucial for optimizing the initial welcome screen load time
// By enabling caching, we ensure that pre-loaded images are stored in the browser cache // By enabling caching, we ensure that pre-loaded images are stored in the browser cache
// This allows for a smoother enter animation and improved user experience on the welcome screen // This allows for a smoother enter animation and improved user experience on the welcome screen
r.Use(func(c *gin.Context) { r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/static/assets/immutable/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.Next()
return
}
if strings.HasPrefix(c.Request.URL.Path, "/static/") { if strings.HasPrefix(c.Request.URL.Path, "/static/") {
ext := filepath.Ext(c.Request.URL.Path) ext := filepath.Ext(c.Request.URL.Path)
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" { if slices.Contains(cachableFileExtensions, ext) {
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
} }
} }
c.Next() c.Next()
}) })
r.StaticFS("/static", http.FS(staticFS)) r.GET("/robots.txt", func(c *gin.Context) {
c.Header("Content-Type", "text/plain")
c.Header("Cache-Control", "public, max-age=31536000, immutable") // Cache for 1 year
c.String(http.StatusOK, "User-agent: *\nDisallow: /")
})
r.Any("/static/*w", func(c *gin.Context) {
staticFileServer.ServeHTTP(c.Writer, c.Request)
})
// Public routes (no authentication required)
r.POST("/auth/login-local", handleLogin) r.POST("/auth/login-local", handleLogin)
// We use this to determine if the device is setup // We use this to determine if the device is setup
@ -536,14 +566,31 @@ func RunWebServer() {
r := setupRouter() r := setupRouter()
// Determine the binding address based on the config // Determine the binding address based on the config
bindAddress := ":80" // Default to all interfaces var bindAddress string
listenPort := 80 // default port
useIPv4 := config.NetworkConfig.IPv4Mode.String != "disabled"
useIPv6 := config.NetworkConfig.IPv6Mode.String != "disabled"
if config.LocalLoopbackOnly { if config.LocalLoopbackOnly {
bindAddress = "localhost:80" // Loopback only (both IPv4 and IPv6) if useIPv4 && useIPv6 {
bindAddress = fmt.Sprintf("localhost:%d", listenPort)
} else if useIPv4 {
bindAddress = fmt.Sprintf("127.0.0.1:%d", listenPort)
} else if useIPv6 {
bindAddress = fmt.Sprintf("[::1]:%d", listenPort)
}
} else {
if useIPv4 && useIPv6 {
bindAddress = fmt.Sprintf(":%d", listenPort)
} else if useIPv4 {
bindAddress = fmt.Sprintf("0.0.0.0:%d", listenPort)
} else if useIPv6 {
bindAddress = fmt.Sprintf("[::]:%d", listenPort)
}
} }
logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server") logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
err := r.Run(bindAddress) if err := r.Run(bindAddress); err != nil {
if err != nil {
panic(err) panic(err)
} }
} }