Compare commits

...

41 Commits

Author SHA1 Message Date
Aveline 5452d7c721
release 0.3.9 (#349) 2025-04-10 16:47:19 +02:00
Adam Shiervani 3fbcb7e5c4
chore(dev_deploy): update logging for websocket in deployment script (#348) 2025-04-10 16:09:37 +02:00
Aveline 76a1825b02
chore(websocket): logging and metrics improvement (#347)
* chore(websocket): only show warning if websocket is closed abnormally

* chore(websocket): add counter for ping requests received
2025-04-10 15:53:26 +02:00
Aveline 4137b82661
feat(websocket): handle ping messages sent from react and add logging (#346) 2025-04-10 15:10:22 +02:00
Adam Shiervani 929e3a66d7
fix(ui): adjust layout and z-index for improved UI consistency in KvmIdRoute (#345) 2025-04-10 12:39:36 +02:00
Adam Shiervani 101032b816
fix(ui): update WebRTCVideo component to properly animate on peer connection state (#343) 2025-04-10 11:55:28 +02:00
Ben Kochie 6618ee4c6e
fix: Shell linting (#328)
Cleanup various shell linting issues
* Use `/usr/bin/env` consistently for better platform compatibility.
* SC2317 (info): Command appears to be unreachable.
* SC2002 (style): Useless cat.

Signed-off-by: SuperQ <superq@gmail.com>
2025-04-10 00:30:33 +02:00
Adam Shiervani 02bf869b99
fix(ui): increase z-index for Modal component to improve layering (#341) 2025-04-10 00:05:51 +02:00
Adam Shiervani d824a1bc86
fix(dev_device): update JETKVM_PROXY_URL to use WebSocket protocol (#342) 2025-04-10 00:05:41 +02:00
Adam Shiervani ee29cb11bd
Don't block new PC if connection is stable. No need to (#340) 2025-04-09 23:26:02 +02:00
Aveline bf89e038ee
Re-add old signaling for when upgrading (#339) 2025-04-09 22:27:01 +02:00
Adam Shiervani 0b6be9b644 refactor: remove unnecessary whitespace in setupRouter function 2025-04-09 22:25:47 +02:00
Adam Shiervani 1656420b3b re-add old signaling for when upgrading 2025-04-09 22:23:05 +02:00
Aveline 7e83e24e07
fix(ota): certificate signed by unknown authority (#338) 2025-04-09 20:29:03 +02:00
Siyuan Miao 652e845d83 fix(ota): certificate signed by unknown authority 2025-04-09 20:25:26 +02:00
Aveline 58f42e0d16
bump to 0.3.9 2025-04-09 19:59:41 +02:00
Aveline e748346e2b
release 0.3.9 (#337) 2025-04-09 19:57:56 +02:00
Adam Shiervani 1a30977085
Feat/Trickle ice (#336)
* feat(cloud): Use Websocket signaling in cloud mode

* refactor: Enhance WebRTC signaling and connection handling

* refactor: Improve WebRTC connection management and logging in KvmIdRoute

* refactor: Update PeerConnectionDisconnectedOverlay to use Card component for better UI structure

* refactor: Standardize metric naming and improve websocket logging

* refactor: Rename WebRTC signaling functions and update deployment script for debug version

* fix: Handle error when writing new ICE candidate to WebRTC signaling channel

* refactor: Rename signaling handler function for clarity

* refactor: Remove old http local http endpoint

* refactor: Improve metric help text and standardize comparison operator in KvmIdRoute

* chore(websocket): use MetricVec instead of Metric to store metrics

* fix conflicts

* fix: use wss when the page is served over https

* feat: Add app version header and update WebRTC signaling endpoint

* fix: Handle error when writing device metadata to WebRTC signaling channel

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-04-09 00:10:38 +02:00
Aveline fa1b11b228
chore(ota): allow a longer timeout when downloading packages (#332) 2025-04-08 00:43:03 +02:00
Aveline abc6d92331
feat(cloud): disconnect from cloud immediately when cloud URL changes… (#326) 2025-04-07 14:19:43 +02:00
Siyuan Miao 73e715117e feat(cloud): disconnect from cloud immediately when cloud URL changes or user requests to deregister 2025-04-04 13:16:38 +02:00
Adam Shiervani 8268b20f32
refactor: Update WebRTC connection handling and overlays (#320)
* refactor: Update WebRTC connection handling and overlays

* fix: Update comments for WebRTC connection handling in KvmIdRoute

* chore: Clean up import statements in devices.$id.tsx
2025-04-03 19:32:14 +02:00
Aveline 1a26431147
chore(cloud): websocket client improvements (#323) 2025-04-03 19:28:37 +02:00
Siyuan Miao f3b5011d65 feat(cloud): add metrics for cloud connections 2025-04-03 19:06:21 +02:00
Siyuan Miao 1e9adf81d4 chore: skip websocket client if net isn't up or time sync hasn't complete 2025-04-03 18:16:41 +02:00
Aveline 65e4a58ad9
chore: Update README Discord Link (#308) 2025-03-31 06:05:30 +02:00
Cameron Fleming df0d083a28
chore: Update README Discord Link
Corrects Discord link in the help section.
2025-03-29 21:13:59 +00:00
Aveline 1f8f885a1d
chore: Enable more linters (#255) 2025-03-28 10:21:49 +01:00
SuperQ aed453cc8c
chore: Enable more linters
Enable more golangci-lint linters.
* `forbidigo` to stop use of non-logger console printing.
* `goimports` to make sure `import` blocks are formatted nicely.
* `misspell` to catch spelling mistakes.
* `whitespace` to catch whitespace issues.

Signed-off-by: SuperQ <superq@gmail.com>
2025-03-26 18:41:09 +01:00
Aveline edafe996a9
chore: fix linting issues of web_tls.go (#287) 2025-03-26 18:32:55 +01:00
Aveline a9180c972c
chore: move smoketest to private repo (#291) 2025-03-26 18:02:03 +01:00
Siyuan Miao b5e0f894bc chore: move smoketest to private repo 2025-03-25 18:42:26 +01:00
Adam Shiervani a3580b5465
Improve error handling when `RTCPeerConnection` throws (#289)
* fix(WebRTC): improve error handling during peer connection creation and add connection error overlay

* refactor: update peer connection state handling and improve type definitions across components
2025-03-25 14:54:04 +01:00
Adam Shiervani 3b711db781
Apply and Upgrade Eslint (#288)
* Upgrade ESLINT and fix issues

* feat: add frontend linting job to GitHub Actions workflow

* Move UI linting to separate file

* More linting fixes

* Remove pull_request trigger from UI linting workflow

* Update UI linting workflow

* Rename frontend-lint workflow to ui-lint for clarity
2025-03-25 11:56:24 +01:00
Adam Shiervani 9d511d7f58
Autoplay permission handling (#285)
* feat(WebRTC): enhance connection management with connection failures after X attempts or a certain time

* refactor(WebRTC): simplify WebRTCVideo component and enhance connection error handling

* fix(WebRTC): extend connection timeout from 1 second to 60 seconds for improved error handling

* feat(VideoOverlay): add NoAutoplayPermissionsOverlay component and improve HDMIErrorOverlay content

* feat(VideoOverlay): update NoAutoplayPermissionsOverlay styling and improve user instructions

* Remove unused PlayIcon import to clean up code
2025-03-24 23:32:13 +01:00
Adam Shiervani 5d7d4db4aa
Improve connection error handling (#284)
* feat(WebRTC): enhance connection management with connection failures after X attempts or a certain time

* refactor(WebRTC): simplify WebRTCVideo component and enhance connection error handling

* fix(WebRTC): extend connection timeout from 1 second to 60 seconds for improved error handling
2025-03-24 23:31:23 +01:00
Aveline 0a7847c5ab
fix: create empty resource directory to avoid static type check failure (#286) 2025-03-24 23:29:46 +01:00
Siyuan Miao 1b8954e9f3 chore: fix linting issues of web_tls.go 2025-03-24 23:20:08 +01:00
Siyuan Miao ab03aded74 chore: create empty resource directory to avoid static type check fail 2025-03-24 23:16:17 +01:00
Adam Shiervani 204e6c7faf feat(UsbDeviceSetting): integrate remote virtual media state management and improve USB config handlingt 2025-03-24 12:32:12 +01:00
Adam Shiervani caf3922ecd
refactor(WebRTCVideo): improve mouse event handling and video playback logic (#282) 2025-03-24 12:07:31 +01:00
107 changed files with 3080 additions and 1640 deletions

View File

@ -12,6 +12,7 @@ jobs:
build: build:
runs-on: buildjet-4vcpu-ubuntu-2204 runs-on: buildjet-4vcpu-ubuntu-2204
name: Build name: Build
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -19,12 +20,12 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: v21.1.0 node-version: v21.1.0
cache: 'npm' cache: "npm"
cache-dependency-path: '**/package-lock.json' cache-dependency-path: "**/package-lock.json"
- name: Set up Golang - name: Set up Golang
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.24.0' go-version: "1.24.0"
- name: Build frontend - name: Build frontend
run: | run: |
make frontend make frontend
@ -35,108 +36,4 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: jetkvm-app name: jetkvm-app
path: bin/jetkvm_app path: bin/jetkvm_app
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
needs: build
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: jetkvm-app
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log

View File

@ -27,6 +27,9 @@ jobs:
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with: with:
go-version: 1.23.x go-version: 1.23.x
- name: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint - name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with: with:

122
.github/workflows/smoketest.yml vendored Normal file
View File

@ -0,0 +1,122 @@
name: smoketest
on:
repository_dispatch:
types: [smoketest]
jobs:
ghbot_payload:
name: Ghbot payload
runs-on: ubuntu-latest
steps:
- name: "GH_CHECK_RUN_ID=${{ github.event.client_payload.check_run_id }}"
run: |
echo "== START GHBOT_PAYLOAD =="
cat <<'GHPAYLOAD_EOF' | base64
${{ toJson(github.event.client_payload) }}
GHPAYLOAD_EOF
echo "== END GHBOT_PAYLOAD =="
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
run: |
wget -O /tmp/jk.zip "${{ github.event.client_payload.artifact_download_url }}"
unzip /tmp/jk.zip
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Waiting for 10 seconds to allow all services to start"
sleep 10
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log

34
.github/workflows/ui-lint.yml vendored Normal file
View File

@ -0,0 +1,34 @@
---
name: ui-lint
on:
push:
paths:
- "ui/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/ui-lint.yml"
permissions:
contents: read
jobs:
ui-lint:
name: UI Lint
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
cache: "npm"
cache-dependency-path: "ui/package-lock.json"
- name: Install dependencies
run: |
cd ui
npm ci
- name: Lint UI
run: |
cd ui
npm run lint

View File

@ -1,12 +1,22 @@
--- ---
linters: linters:
enable: enable:
# - goimports - forbidigo
# - misspell - goimports
- misspell
# - revive # - revive
- whitespace
issues: issues:
exclude-rules: exclude-rules:
- path: _test.go - path: _test.go
linters: linters:
- errcheck - errcheck
linters-settings:
forbidigo:
forbid:
- p: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.

View File

@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z) BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s) BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD) REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M) VERSION_DEV := 0.4.0-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.8 VERSION := 0.3.9
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm KVM_PKG_NAME := github.com/jetkvm/kvm

View File

@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
## I need help ## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW). The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
## I want to report an issue ## I want to report an issue

271
cloud.go
View File

@ -4,12 +4,16 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"time" "time"
"github.com/coder/websocket/wsjson" "github.com/coder/websocket/wsjson"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
@ -32,10 +36,134 @@ const (
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests // CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api // should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud // WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second WebsocketPingInterval = 15 * time.Second
) )
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp",
Help: "The timestamp when the cloud connection was established",
},
)
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp",
Help: "The timestamp when the last ping response was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp",
Help: "The timestamp when the last ping request was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration",
Help: "The duration of the last ping response",
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_sent",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_received",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_requests",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp",
Help: "The timestamp of the last session request",
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
[]string{"type", "source"},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_count",
Help: "The number of times the cloud connection has failed",
},
)
)
var (
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
if sourceType != "cloud" {
return
}
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) { func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest var req CloudRegisterRequest
@ -90,11 +218,6 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
if config.CloudToken == "" {
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken config.CloudToken = tokenResp.SecretToken
provider, err := oidc.NewProvider(c, "https://accounts.google.com") provider, err := oidc.NewProvider(c, "https://accounts.google.com")
@ -125,74 +248,78 @@ func handleCloudRegister(c *gin.Context) {
c.JSON(200, gin.H{"message": "Cloud registration successful"}) c.JSON(200, gin.H{"message": "Cloud registration successful"})
} }
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Tracef("cloud disconnect channel is not set, no need to disconnect")
return
}
// just in case the channel is closed, we don't want to panic
defer func() {
if r := recover(); r != nil {
cloudLogger.Infof("cloud disconnect channel is closed, no need to disconnect: %v", r)
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error { func runWebsocketClient() error {
if config.CloudToken == "" { if config.CloudToken == "" {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set") return fmt.Errorf("cloud token is not set")
} }
wsURL, err := url.Parse(config.CloudURL) wsURL, err := url.Parse(config.CloudURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err) return fmt.Errorf("failed to parse config.CloudURL: %w", err)
} }
if wsURL.Scheme == "http" { if wsURL.Scheme == "http" {
wsURL.Scheme = "ws" wsURL.Scheme = "ws"
} else { } else {
wsURL.Scheme = "wss" wsURL.Scheme = "wss"
} }
header := http.Header{} header := http.Header{}
header.Set("X-Device-ID", GetDeviceID()) header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken) header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout) dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial() defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header, HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
websocketLogger.Infof("ping frame received: %v, source: %s, sourceType: cloud", payload, wsURL.Host)
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
return true
},
}) })
// if the context is canceled, we don't want to return an error
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Infof("websocket connection canceled")
return nil
}
return err return err
} }
defer c.CloseNow() //nolint:errcheck defer c.CloseNow() //nolint:errcheck
cloudLogger.Infof("websocket connected to %s", wsURL) cloudLogger.Infof("websocket connected to %s", wsURL)
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
go func() {
for {
time.Sleep(CloudWebSocketPingInterval)
err := c.Ping(runCtx)
if err != nil {
cloudLogger.Warnf("websocket ping error: %v", err)
cancelRun()
return
}
}
}()
for {
typ, msg, err := c.Read(runCtx)
if err != nil {
return err
}
if typ != websocket.MessageText {
// ignore non-text messages
continue
}
var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req)
if err != nil {
cloudLogger.Warnf("unable to parse ws message: %v", string(msg))
continue
}
cloudLogger.Infof("new session request: %v", req.OidcGoogle) // set the metrics when we successfully connect to the cloud.
cloudLogger.Tracef("session request info: %v", req) wsResetMetrics(true, "cloud", wsURL.Host)
err = handleSessionRequest(runCtx, c, req) // we don't have a source for the cloud connection
if err != nil { return handleWebRTCSignalWsMessages(c, true, wsURL.Host)
cloudLogger.Infof("error starting new session: %v", err)
continue
}
}
} }
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout) oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC() defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
@ -220,10 +347,35 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
return fmt.Errorf("google identity mismatch") return fmt.Errorf("google identity mismatch")
} }
return nil
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest, isCloudConnection bool, source string) error {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
}))
defer timer.ObserveDuration()
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
return err
}
}
session, err := newSession(SessionConfig{ session, err := newSession(SessionConfig{
ICEServers: req.ICEServers, ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP, LocalIP: req.IP,
IsCloud: true, ICEServers: req.ICEServers,
}) })
if err != nil { if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err}) _ = wsjson.Write(context.Background(), c, gin.H{"error": err})
@ -247,15 +399,37 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
cloudLogger.Info("new session accepted") cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", session) cloudLogger.Tracef("new session accepted: %v", session)
currentSession = session currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd}) _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil return nil
} }
func RunWebsocketClient() { func RunWebsocketClient() {
for { for {
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.Up {
cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSyncSuccess {
cloudLogger.Warn("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient() err := runWebsocketClient()
if err != nil { if err != nil {
cloudLogger.Errorf("websocket client error: %v", err) cloudLogger.Errorf("websocket client error: %v", err)
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }
@ -305,6 +479,9 @@ func rpcDeregisterDevice() error {
return fmt.Errorf("failed to save configuration after deregistering: %w", err) return fmt.Errorf("failed to save configuration after deregistering: %w", err)
} }
cloudLogger.Infof("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
return nil return nil
} }

View File

@ -1,3 +1,5 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status # Exit immediately if a command exits with a non-zero status
set -e set -e
@ -16,7 +18,6 @@ show_help() {
echo "Example:" echo "Example:"
echo " $0 -r 192.168.0.17" echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin" echo " $0 -r 192.168.0.17 -u admin"
exit 0
} }
# Default values # Default values
@ -70,10 +71,10 @@ cd bin
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host # Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug" ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
# Deploy and run the application on the remote host # Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e set -e
# Set the library path to include the directory where librockit.so is located # Set the library path to include the directory where librockit.so is located
@ -84,13 +85,13 @@ killall jetkvm_app || true
killall jetkvm_app_debug || true killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored # Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH" cd "${REMOTE_PATH}"
# Make the new binary executable # Make the new binary executable
chmod +x jetkvm_app_debug chmod +x jetkvm_app_debug
# Run the application in the background # Run the application in the background
PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug
EOF EOF
echo "Deployment complete." echo "Deployment complete."

2
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.21.1
require ( require (
github.com/Masterminds/semver/v3 v3.3.0 github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1 github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.12 github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23 github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1

2
go.sum
View File

@ -18,6 +18,8 @@ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=

View File

@ -771,9 +771,14 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
} }
func rpcSetCloudUrl(apiUrl string, appUrl string) error { func rpcSetCloudUrl(apiUrl string, appUrl string) error {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl config.CloudURL = apiUrl
config.CloudAppURL = appUrl config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err) return fmt.Errorf("failed to save config: %w", err)
} }

1
log.go
View File

@ -6,3 +6,4 @@ import "github.com/pion/logging"
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC // ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm") var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud") var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")
var websocketLogger = logging.NewDefaultLoggerFactory().NewLogger("websocket")

View File

@ -72,11 +72,9 @@ func Main() {
if config.TLSMode != "" { if config.TLSMode != "" {
go RunWebSecureServer() go RunWebSecureServer()
} }
// If the cloud token isn't set, the client won't be started by default. // As websocket client already checks if the cloud token is set, we can start it here.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client. go RunWebsocketClient()
if config.CloudToken != "" {
go RunWebsocketClient()
}
initSerialPort() initSerialPort()
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

33
ntp.go
View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os/exec" "os/exec"
"strconv"
"time" "time"
"github.com/beevik/ntp" "github.com/beevik/ntp"
@ -20,13 +21,41 @@ const (
) )
var ( var (
builtTimestamp string
timeSyncRetryInterval = 0 * time.Second timeSyncRetryInterval = 0 * time.Second
timeSyncSuccess = false
defaultNTPServers = []string{ defaultNTPServers = []string{
"time.cloudflare.com", "time.cloudflare.com",
"time.apple.com", "time.apple.com",
} }
) )
func isTimeSyncNeeded() bool {
if builtTimestamp == "" {
logger.Warnf("Built timestamp is not set, time sync is needed")
return true
}
ts, err := strconv.Atoi(builtTimestamp)
if err != nil {
logger.Warnf("Failed to parse built timestamp: %v", err)
return true
}
// builtTimestamp is UNIX timestamp in seconds
builtTime := time.Unix(int64(ts), 0)
now := time.Now()
logger.Tracef("Built time: %v, now: %v", builtTime, now)
if now.Sub(builtTime) < 0 {
logger.Warnf("System time is behind the built time, time sync is needed")
return true
}
return false
}
func TimeSyncLoop() { func TimeSyncLoop() {
for { for {
if !networkState.checked { if !networkState.checked {
@ -40,6 +69,9 @@ func TimeSyncLoop() {
continue continue
} }
// check if time sync is needed, but do nothing for now
isTimeSyncNeeded()
logger.Infof("Syncing system time") logger.Infof("Syncing system time")
start := time.Now() start := time.Now()
err := SyncSystemTime() err := SyncSystemTime()
@ -56,6 +88,7 @@ func TimeSyncLoop() {
continue continue
} }
timeSyncSuccess = true
logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start)) logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
time.Sleep(timeSyncInterval) // after the first sync is done time.Sleep(timeSyncInterval) // after the first sync is done
} }

8
ota.go
View File

@ -126,7 +126,13 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err) return fmt.Errorf("error creating request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) // TODO: set a separate timeout for the download but keep the TLS handshake short
// use Transport here will cause CA certificate validation failure so we temporarily removed it
client := http.Client{
Timeout: 10 * time.Minute,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("error downloading file: %w", err) return fmt.Errorf("error downloading file: %w", err)
} }

View File

@ -1,15 +1,11 @@
package kvm package kvm
import ( import (
"net/http"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/version" "github.com/prometheus/common/version"
) )
var promHandler http.Handler
func initPrometheus() { func initPrometheus() {
// A Prometheus metrics endpoint. // A Prometheus metrics endpoint.
version.Version = builtAppVersion version.Version = builtAppVersion

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# Check if a commit message was provided # Check if a commit message was provided
if [ -z "$1" ]; then if [ -z "$1" ]; then
@ -26,7 +26,7 @@ git checkout -b release-temp
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main git reset --soft public/main
else else
git reset --soft $(git rev-list --max-parents=0 HEAD) git reset --soft "$(git rev-list --max-parents=0 HEAD)"
fi fi
# Merge changes from main # Merge changes from main

View File

@ -66,7 +66,6 @@ func runATXControl() {
newLedPWRState != ledPWRState || newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState || newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState { newBtnPWRState != btnPWRState {
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v", logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState) newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)

View File

@ -8,6 +8,8 @@ module.exports = {
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react/jsx-runtime", "plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
], ],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"], ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
@ -20,5 +22,45 @@ module.exports = {
}, },
rules: { rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"import/order": [
"error",
{
/**
* @description
*
* This keeps imports separate from one another, ensuring that imports are separated
* by their relative groups. As you move through the groups, imports become closer
* to the current file.
*
* @example
* ```
* import fs from 'fs';
*
* import package from 'npm-package';
*
* import xyz from '~/project-file';
*
* import index from '../';
*
* import sibling from './foo';
* ```
*/
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],
},
settings: {
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
}, },
}; };

View File

@ -5,11 +5,7 @@
"useTabs": false, "useTabs": false,
"arrowParens": "avoid", "arrowParens": "avoid",
"singleQuote": false, "singleQuote": false,
"plugins": [ "plugins": ["prettier-plugin-tailwindcss"],
"prettier-plugin-tailwindcss" "tailwindFunctions": ["clsx"],
],
"tailwindFunctions": [
"clsx"
],
"printWidth": 90 "printWidth": 90
} }

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# Check if an IP address was provided as an argument # Check if an IP address was provided as an argument
if [ -z "$1" ]; then if [ -z "$1" ]; then
@ -16,4 +16,4 @@ echo "└───────────────────────
# Set the environment variable and run Vite # Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address" echo "Starting development server with JetKVM device at: $ip_address"
sleep 1 sleep 1
JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device

1961
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,13 @@
"build:device": "tsc && vite build --mode=device --emptyOutDir", "build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:staging": "tsc && vite build --mode=cloud-staging", "build:staging": "tsc && vite build --mode=cloud-staging",
"build:prod": "tsc && vite build --mode=cloud-production", "build:prod": "tsc && vite build --mode=cloud-production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint './src/**/*.{ts,tsx}'",
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.2", "@headlessui/tailwindcss": "^0.2.1",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
@ -27,46 +28,50 @@
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1", "cva": "^1.0.0-beta.1",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^10.2.3",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.4.1",
"react-icons": "^5.5.0", "react-icons": "^5.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112", "react-simple-keyboard": "^3.7.112",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.9", "react-xtermjs": "^1.0.9",
"recharts": "^2.15.1", "recharts": "^2.15.0",
"semver": "^7.7.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.0",
"validator": "^13.12.0", "validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
"@types/validator": "^13.12.2", "@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^8.25.0",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.20.0",
"eslint-plugin-react": "^7.34.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react": "^7.37.4",
"postcss": "^8.5.3", "eslint-plugin-react-hooks": "^5.1.0",
"prettier": "^3.5.2", "eslint-plugin-react-refresh": "^0.4.19",
"prettier-plugin-tailwindcss": "^0.5.13", "postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^5.7.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^5.1.4"
} }
} }

View File

@ -1,3 +1,10 @@
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { import {
useHidStore, useHidStore,
@ -5,19 +12,13 @@ import {
useSettingsStore, useSettingsStore,
useUiStore, useUiStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container"; import Container from "@components/Container";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal"; import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import MountPopopover from "@/components/popovers/MountPopover";
import MountPopopover from "./popovers/MountPopover"; import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { Fragment, useCallback, useRef } from "react"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function Actionbar({ export default function Actionbar({
requestFullscreen, requestFullscreen,

View File

@ -1,21 +1,22 @@
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons"; import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar"; import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container"; import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter"; import StepCounter from "@components/StepCounter";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
type AuthLayoutProps = { interface AuthLayoutProps {
title: string; title: string;
description: string; description: string;
action: string; action: string;
cta: string; cta: string;
ctaHref: string; ctaHref: string;
showCounter?: boolean; showCounter?: boolean;
}; }
export default function AuthLayout({ export default function AuthLayout({
title, title,
@ -46,8 +47,8 @@ export default function AuthLayout({
} }
/> />
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="isolate flex h-full w-full items-center justify-center">
<div className="max-w-2xl -mt-16 space-y-8"> <div className="-mt-16 max-w-2xl space-y-8">
{showCounter ? ( {showCounter ? (
<div className="text-center"> <div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} /> <StepCounter currStepIdx={0} nSteps={2} />
@ -61,11 +62,8 @@ export default function AuthLayout({
</div> </div>
<Fieldset className="space-y-12"> <Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4"> <div className="mx-auto max-w-sm space-y-4">
<form <form action={`${CLOUD_API}/oidc/google`} method="POST">
action={`${CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/} {/*This could be the KVM ID*/}
{deviceId ? ( {deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} /> <input type="hidden" name="deviceId" value={deviceId} />

View File

@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
import ExtLink from "@/components/ExtLink"; import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config"; import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = { const sizes = {
XS: "h-[28px] px-2 text-xs", XS: "h-[28px] px-2 text-xs",
@ -101,7 +102,7 @@ const iconVariants = cva({
}, },
}); });
type ButtonContentPropsType = { interface ButtonContentPropsType {
text?: string | React.ReactNode; text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null; LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null; TrailingIcon?: React.FC<{ className: string | undefined }> | null;
@ -111,7 +112,7 @@ type ButtonContentPropsType = {
size: keyof typeof sizes; size: keyof typeof sizes;
theme: keyof typeof themes; theme: keyof typeof themes;
loading?: boolean; loading?: boolean;
}; }
function ButtonContent(props: ButtonContentPropsType) { function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } = const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =

View File

@ -1,10 +1,11 @@
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
type CardPropsType = { interface CardPropsType {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
}; }
export const GridCard = ({ export const GridCard = ({
children, children,

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
type Props = { interface Props {
headline: string; headline: string;
description?: string | React.ReactNode; description?: string | React.ReactNode;
Button?: React.ReactNode; Button?: React.ReactNode;
}; }
export const CardHeader = ({ headline, description, Button }: Props) => { export const CardHeader = ({ headline, description, Button }: Props) => {
return ( return (

View File

@ -1,7 +1,8 @@
import type { Ref } from "react"; import type { Ref } from "react";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx"; import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { cva, cx } from "@/cva.config"; import { cva, cx } from "@/cva.config";
const sizes = { const sizes = {
@ -52,7 +53,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>( const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel( function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props }, { label, id, description, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>, ref: Ref<HTMLInputElement>,
) { ) {
return ( return (

View File

@ -1,4 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) { function Container({ children, className }: { children: ReactNode; className?: string }) {

View File

@ -1,8 +1,8 @@
import Card from "@components/Card"; import Card from "@components/Card";
export type CustomTooltipProps = { export interface CustomTooltipProps {
payload: { payload: { date: number; stat: number }; unit: string }[]; payload: { payload: { date: number; stat: number }; unit: string }[];
}; }
export default function CustomTooltip({ payload }: CustomTooltipProps) { export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) { if (payload?.length) {

View File

@ -1,14 +1,16 @@
import { GridCard } from "@/components/Card";
import React from "react"; import React from "react";
import { GridCard } from "@/components/Card";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
type Props = { interface Props {
IconElm?: React.FC<any>; IconElm?: React.FC<{ className: string | undefined }>;
headline: string; headline: string;
description?: string | React.ReactNode; description?: string | React.ReactNode;
BtnElm?: React.ReactNode; BtnElm?: React.ReactNode;
className?: string; className?: string;
}; }
export default function EmptyCard({ export default function EmptyCard({
IconElm, IconElm,
@ -27,10 +29,16 @@ export default function EmptyCard({
> >
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]"> <div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2"> <div className="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />} {IconElm && (
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4> <IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" />
)}
<h4 className="text-base font-bold leading-none text-black dark:text-white">
{headline}
</h4>
</div> </div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p> <p className="mx-auto text-sm text-slate-600 dark:text-slate-400">
{description}
</p>
</div> </div>
{BtnElm} {BtnElm}
</div> </div>

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
export default function ExtLink({ export default function ExtLink({

View File

@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useFeatureFlag } from "../hooks/useFeatureFlag"; import { useFeatureFlag } from "../hooks/useFeatureFlag";
export function FeatureFlag({ export function FeatureFlag({

View File

@ -1,13 +1,14 @@
import React from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
type Props = { interface Props {
label: string | React.ReactNode; label: string | React.ReactNode;
id?: string; id?: string;
as?: "label" | "span"; as?: "label" | "span";
description?: string | React.ReactNode | null; description?: string | React.ReactNode | null;
disabled?: boolean; disabled?: boolean;
}; }
export default function FieldLabel({ export default function FieldLabel({
label, label,
id, id,

View File

@ -9,7 +9,7 @@ export default function Fieldset({
disabled, disabled,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
fetcher?: FetcherWithComponents<any>; fetcher?: FetcherWithComponents<unknown>;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
}) { }) {

View File

@ -2,19 +2,22 @@ import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid"; import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton } from "@headlessui/react"; import { Menu, MenuButton } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu";
import Container from "@/components/Container"; import Container from "@/components/Container";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus"; import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard"; import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "../api"; import api from "../api";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button"; import { Button, LinkButton } from "./Button";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
interface NavbarProps { interface NavbarProps {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -33,7 +36,7 @@ export default function DashboardNavbar({
picture, picture,
kvmName, kvmName,
}: NavbarProps) { }: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState); const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setUser = useUserStore(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate(); const navigate = useNavigate();
const onLogout = useCallback(async () => { const onLogout = useCallback(async () => {
@ -86,7 +89,7 @@ export default function DashboardNavbar({
<div className="hidden w-[159px] md:block"> <div className="hidden w-[159px] md:block">
<USBStateStatus <USBStateStatus
state={usbState} state={usbState}
peerConnectionState={peerConnectionState} peerConnectionState={peerConnectionState}
/> />
</div> </div>
</div> </div>

View File

@ -1,3 +1,5 @@
import { useEffect } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
useHidStore, useHidStore,
@ -6,7 +8,6 @@ import {
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() { export default function InfoBar() {

View File

@ -1,7 +1,8 @@
import type { Ref } from "react"; import type { Ref } from "react";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx"; import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
@ -84,7 +85,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
{(label || description) && ( {(label || description) && (
<FieldLabel label={label} id={id} description={description} /> <FieldLabel label={label} id={id} description={description} />
)} )}
<InputField ref={ref as any} id={id} {...props} /> <InputField ref={ref as never} id={id} {...props} />
</div> </div>
); );
}, },

View File

@ -1,10 +1,11 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md"; import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu"; import { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card";
import { Button, LinkButton } from "@components/Button";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string { function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed // Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime(); const timeMs = typeof date === "number" ? date : date.getTime();

View File

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
const Modal = React.memo(function Modal({ const Modal = React.memo(function Modal({
@ -14,12 +15,12 @@ const Modal = React.memo(function Modal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<Dialog open={open} onClose={onClose} className="relative z-10"> <Dialog open={open} onClose={onClose} className="relative z-20">
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90" className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
/> />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto"> <div className="fixed inset-0 z-20 w-screen overflow-y-auto">
{/* TODO: This doesn't work well with other-sessions */} {/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4"> <div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel <DialogPanel

View File

@ -1,4 +1,5 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard"; import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() { export default function NotFoundPage() {

View File

@ -9,21 +9,22 @@ const PeerConnectionStatusMap = {
failed: "Connection failed", failed: "Connection failed",
closed: "Closed", closed: "Closed",
new: "Connecting", new: "Connecting",
}; } as Record<RTCPeerConnectionState | "error" | "closing", string>;
export type PeerConnections = keyof typeof PeerConnectionStatusMap; export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = { type StatusProps = Record<
[key in PeerConnections]: { PeerConnections,
{
statusIndicatorClassName: string; statusIndicatorClassName: string;
}; }
}; >;
export default function PeerConnectionStatusCard({ export default function PeerConnectionStatusCard({
state, state,
title, title,
}: { }: {
state?: PeerConnections; state?: RTCPeerConnectionState | null;
title?: string; title?: string;
}) { }) {
if (!state) return null; if (!state) return null;

View File

@ -1,9 +1,12 @@
import React from "react"; import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx"; import clsx from "clsx";
import Card from "./Card";
import FieldLabel from "@/components/FieldLabel";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
import Card from "./Card";
type SelectMenuProps = Pick< type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"], JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value" "disabled" | "onChange" | "name" | "value"

View File

@ -1,10 +1,11 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import React from "react"; import React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode }; interface Props { logoHref?: string; actionElement?: React.ReactNode }
export default function SimpleNavbar({ logoHref, actionElement }: Props) { export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return ( return (

View File

@ -9,6 +9,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip"; import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({ export default function StatChart({

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
interface Props { interface Props {

View File

@ -1,12 +1,13 @@
import { CheckIcon } from "@heroicons/react/16/solid"; import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config"; import { cva, cx } from "@/cva.config";
import Card from "@/components/Card"; import Card from "@/components/Card";
type Props = { interface Props {
nSteps: number; nSteps: number;
currStepIdx: number; currStepIdx: number;
size?: keyof typeof sizes; size?: keyof typeof sizes;
}; }
const sizes = { const sizes = {
SM: "text-xs leading-[12px]", SM: "text-xs leading-[12px]",

View File

@ -1,8 +1,5 @@
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "@/cva.config";
import { useEffect } from "react"; import { useEffect } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
@ -11,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11"; import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard"; import { ClipboardAddon } from "@xterm/addon-clipboard";
import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2"); const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration // Terminal theme configuration

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx"; import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { FieldError } from "@/components/InputField"; import { FieldError } from "@/components/InputField";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";

View File

@ -1,23 +1,23 @@
import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards"; import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores"; import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"]; type USBStates = HidState["usbState"];
type StatusProps = { type StatusProps = Record<
[key in USBStates]: { USBStates,
{
icon: React.FC<{ className: string | undefined }>; icon: React.FC<{ className: string | undefined }>;
iconClassName: string; iconClassName: string;
statusIndicatorClassName: string; statusIndicatorClassName: string;
}; }
}; >;
const USBStateMap: { const USBStateMap: Record<USBStates, string> = {
[key in USBStates]: string;
} = {
configured: "Connected", configured: "Connected",
attached: "Connecting", attached: "Connecting",
addressed: "Connecting", addressed: "Connecting",
@ -30,9 +30,8 @@ export default function USBStateStatus({
peerConnectionState, peerConnectionState,
}: { }: {
state: USBStates; state: USBStates;
peerConnectionState?: RTCPeerConnectionState; peerConnectionState?: RTCPeerConnectionState | null;
}) { }) {
const StatusCardProps: StatusProps = { const StatusCardProps: StatusProps = {
configured: { configured: {
icon: ({ className }) => ( icon: ({ className }) => (

View File

@ -1,8 +1,10 @@
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { Button } from "./Button"; import { Button } from "./Button";
import { GridCard } from "./Card"; import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner"; import LoadingSpinner from "./LoadingSpinner";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function UpdateInProgressStatusCard() { export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();

View File

@ -1,15 +1,14 @@
import { useCallback } from "react"; import { useCallback , useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox"; import Checkbox from "./Checkbox";
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic"; import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset"; import Fieldset from "./Fieldset";
export interface USBConfig { export interface USBConfig {
vendor_id: string; vendor_id: string;
product_id: string; product_id: string;
@ -119,13 +118,12 @@ export function UsbDeviceSetting() {
const onUsbConfigItemChange = useCallback( const onUsbConfigItemChange = useCallback(
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setUsbDeviceConfig(val => { setUsbDeviceConfig(prev => ({
val[key] = e.target.checked; ...prev,
handleUsbConfigChange(val); [key]: e.target.checked,
return val; }));
});
}, },
[handleUsbConfigChange], [],
); );
const handlePresetChange = useCallback( const handlePresetChange = useCallback(

View File

@ -1,14 +1,14 @@
import { useMemo } from "react"; import { useMemo , useCallback , useEffect, useState } from "react";
import { useCallback } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { InputFieldWithLabel } from "./InputField";
import { useEffect, useState } from "react";
import { UsbConfigState } from "../hooks/stores"; import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
import { InputFieldWithLabel } from "./InputField";
import { SelectMenuBasic } from "./SelectMenuBasic"; import { SelectMenuBasic } from "./SelectMenuBasic";
import Fieldset from "./Fieldset"; import Fieldset from "./Fieldset";

View File

@ -1,10 +1,12 @@
import React from "react"; import React from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button"; import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
@ -23,18 +25,18 @@ interface LoadingOverlayProps {
show: boolean; show: boolean;
} }
export function LoadingOverlay({ show }: LoadingOverlayProps) { export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
<motion.div <motion.div
className="absolute inset-0 aspect-video h-full w-full" className="aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: show ? 0.3 : 0.1, duration: show ? 0.3 : 0.1,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -53,22 +55,60 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
); );
} }
interface ConnectionErrorOverlayProps { interface LoadingConnectionOverlayProps {
show: boolean; show: boolean;
text: string;
} }
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
<motion.div <motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full" className="aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{ transition={{
duration: 0.3, duration: 0.4,
ease: "easeInOut" ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
{text}
</p>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
setupPeerConnection: () => Promise<void>;
}
export function ConnectionFailedOverlay({
show,
setupPeerConnection,
}: ConnectionErrorOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -85,14 +125,75 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
<li>Try restarting both the device and your computer</li> <li>Try restarting both the device and your computer</li>
</ul> </ul>
</div> </div>
<div> <div className="flex items-center gap-x-2">
<LinkButton <LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="primary"
text="Troubleshooting Guide" text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
size="SM" size="SM"
/> />
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
size="SM"
theme="light"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface PeerConnectionDisconnectedOverlay {
show: boolean;
}
export function PeerConnectionDisconnectedOverlay({
show,
}: PeerConnectionDisconnectedOverlay) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<Card>
<div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection...
</p>
</div>
</Card>
</div> </div>
</div> </div>
</div> </div>
@ -118,25 +219,27 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<AnimatePresence> <AnimatePresence>
{show && isNoSignal && ( {show && isNoSignal && (
<motion.div <motion.div
className="absolute inset-0 w-full h-full aspect-video" className="absolute inset-0 aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li> <li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li> <li>
Ensure source device is powered on and outputting a signal
</li>
<li> <li>
If using an adapter, it&apos;s compatible and functioning If using an adapter, it&apos;s compatible and functioning
correctly correctly
@ -169,7 +272,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -187,7 +290,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
<div> <div>
<LinkButton <LinkButton
to={"/help/hdmi-error"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="light"
text="Learn more" text="Learn more"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
@ -204,3 +307,54 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</> </>
); );
} }
interface NoAutoplayPermissionsOverlayProps {
show: boolean;
onPlayClick: () => void;
}
export function NoAutoplayPermissionsOverlay({
show,
onPlayClick,
}: NoAutoplayPermissionsOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="space-y-4">
<h2 className="text-2xl font-extrabold text-black dark:text-white">
Autoplay permissions required
</h2>
<div className="space-y-2 text-center">
<div>
<Button
size="MD"
theme="primary"
LeadingIcon={LuPlay}
text="Manually start stream"
onClick={onPlayClick}
/>
</div>
<div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard"; import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
useDeviceSettingsStore, useDeviceSettingsStore,
useHidStore, useHidStore,
@ -15,15 +16,19 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { HDMIErrorOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay"; import {
import { LoadingOverlay } from "./VideoOverlay"; HDMIErrorOverlay,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay,
} from "./VideoOverlay";
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream); const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// Store hooks // Store hooks
const settings = useSettingsStore(); const settings = useSettingsStore();
@ -41,15 +46,13 @@ export default function WebRTCVideo() {
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState); const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying; const isVideoLoading = !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "", // console.log("peerConnection?.connectionState", peerConnection?.connectionState);
);
// Keyboard related states // Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } = const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
@ -82,19 +85,19 @@ export default function WebRTCVideo() {
const onVideoPlaying = useCallback(() => { const onVideoPlaying = useCallback(() => {
setIsPlaying(true); setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]); }, [updateVideoSizeStore]);
// On mount, get the video size // On mount, get the video size
useEffect( useEffect(
function updateVideoSizeOnMount() { function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, },
[setVideoClientSize, updateVideoSizeStore, setVideoSize], [setVideoClientSize, updateVideoSizeStore, setVideoSize],
); );
// Mouse-related // Mouse-related
const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback( const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
@ -168,7 +171,14 @@ export default function WebRTCVideo() {
const { buttons } = e; const { buttons } = e;
sendAbsMouseMovement(x, y, buttons); sendAbsMouseMovement(x, y, buttons);
}, },
[sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], [
sendAbsMouseMovement,
videoClientHeight,
videoClientWidth,
videoWidth,
videoHeight,
settings.mouseMode,
],
); );
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
@ -355,7 +365,68 @@ export default function WebRTCVideo() {
], ],
); );
// Effect hooks const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
const addStreamToVideoElm = useCallback(
(mediaStream: MediaStream) => {
if (!videoElm.current) return;
const videoElmRefValue = videoElm.current;
// console.log("Adding stream to video element", videoElmRefValue);
videoElmRefValue.srcObject = mediaStream;
updateVideoSizeStore(videoElmRefValue);
},
[updateVideoSizeStore],
);
useEffect(
function updateVideoStreamOnNewTrack() {
if (!peerConnection) return;
const abortController = new AbortController();
const signal = abortController.signal;
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
// console.log("Adding stream to video element");
addStreamToVideoElm(e.streams[0]);
},
{ signal },
);
return () => {
abortController.abort();
};
},
[addStreamToVideoElm, peerConnection],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
console.log("Updating video stream from mediaStream");
// We set the as early as possible
addStreamToVideoElm(mediaStream);
},
[
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
);
// Setup Keyboard Events
useEffect( useEffect(
function setupKeyboardEvents() { function setupKeyboardEvents() {
const abortController = new AbortController(); const abortController = new AbortController();
@ -377,47 +448,23 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { // Setup Video Event Listeners
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
useEffect( useEffect(
function setupVideoEventListeners() { function setupVideoEventListeners() {
let videoElmRefValue = null; const videoElmRefValue = videoElm.current;
if (!videoElm.current) return; if (!videoElmRefValue) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); // To prevent the video from being paused when the user presses a space in fullscreen mode
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, // We need to know when the video is playing to update state and video size
passive: true,
});
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => { return () => {
if (videoElmRefValue) abortController.abort(); abortController.abort();
}; };
}, },
[ [
@ -429,6 +476,41 @@ export default function WebRTCVideo() {
], ],
); );
// Setup Absolute Mouse Events
useEffect(
function setAbsoluteMouseModeEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
if (settings.mouseMode !== "absolute") return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
// Reset the mouse position when the window is blurred or the document is hidden
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
return () => {
abortController.abort();
};
},
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode],
);
// Setup Relative Mouse Events
const containerRef = useRef<HTMLDivElement>(null);
useEffect( useEffect(
function setupRelativeMouseEventListeners() { function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
@ -436,50 +518,43 @@ export default function WebRTCVideo() {
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
// bind to body to capture all mouse events // We bind to the larger container in relative mode because of delta between the acceleration of the local
const body = document.querySelector("body"); // mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
if (!body) return; // When we get Pointer Lock support, we can remove this.
const containerElm = containerRef.current;
if (!containerElm) return;
body.addEventListener("mousemove", relMouseMoveHandler, { signal }); containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
body.addEventListener("pointerdown", relMouseMoveHandler, { signal }); containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
body.addEventListener("pointerup", relMouseMoveHandler, { signal }); containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
containerElm.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
return () => { return () => {
abortController.abort(); abortController.abort();
body.removeEventListener("mousemove", relMouseMoveHandler);
body.removeEventListener("pointerdown", relMouseMoveHandler);
body.removeEventListener("pointerup", relMouseMoveHandler);
}; };
}, [settings.mouseMode, relMouseMoveHandler],
)
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
}, },
[ [settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
); );
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false;
if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false;
return true;
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}> <fieldset disabled={peerConnection?.connectionState !== "connected"}>
<Actionbar <Actionbar
requestFullscreen={async () => requestFullscreen={async () =>
videoElm.current?.requestFullscreen({ videoElm.current?.requestFullscreen({
@ -490,7 +565,12 @@ export default function WebRTCVideo() {
</fieldset> </fieldset>
</div> </div>
<div className="h-full overflow-hidden"> <div
ref={containerRef}
className={cx("h-full overflow-hidden", {
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
})}
>
<div className="relative h-full"> <div className="relative h-full">
<div <div
className={cx( className={cx(
@ -519,23 +599,35 @@ export default function WebRTCVideo() {
className={cx( className={cx(
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000", "outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
{ {
"cursor-none": settings.isCursorHidden, "cursor-none":
"opacity-0": isLoading || isConnectionError || hdmiError, settings.mouseMode === "absolute" &&
settings.isCursorHidden,
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
/> />
<div {peerConnection?.connectionState == "connected" && (
style={{ animationDuration: "500ms" }} <div
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0" style={{ animationDuration: "500ms" }}
> className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md"> >
<LoadingOverlay show={isLoading} /> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<ConnectionErrorOverlay show={isConnectionError} /> <LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

@ -1,11 +1,13 @@
import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu"; import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useJsonRpc } from "../../hooks/useJsonRpc"; import { useJsonRpc } from "../../hooks/useJsonRpc";
import LoadingSpinner from "../LoadingSpinner";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
@ -102,11 +104,11 @@ export function ATXPowerControl() {
{atxState === null ? ( {atxState === null ? (
<Card className="flex h-[120px] items-center justify-center p-3"> <Card className="flex h-[120px] items-center justify-center p-3">
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[120px] animate-fadeIn opacity-0"> <Card className="h-[120px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4"> <div className="space-y-4 p-3">
{/* Control Buttons */} {/* Control Buttons */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

@ -1,12 +1,13 @@
import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu"; import { LuPower } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import LoadingSpinner from "../LoadingSpinner"; import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner";
interface DCPowerState { interface DCPowerState {
isOn: boolean; isOn: boolean;
@ -59,11 +60,11 @@ export function DCPowerControl() {
{powerState === null ? ( {powerState === null ? (
<Card className="flex h-[160px] justify-center p-3"> <Card className="flex h-[160px] justify-center p-3">
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[160px] animate-fadeIn opacity-0"> <Card className="h-[160px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4"> <div className="space-y-4 p-3">
{/* Power Controls */} {/* Power Controls */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

@ -1,12 +1,13 @@
import { Button } from "@components/Button";
import { LuTerminal } from "react-icons/lu"; import { LuTerminal } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores"; import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
interface SerialSettings { interface SerialSettings {
baudRate: string; baudRate: string;

View File

@ -1,19 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole"; import { SerialConsole } from "@components/extensions/SerialConsole";
import notifications from "../../notifications"; import { Button } from "@components/Button";
import notifications from "@/notifications";
interface Extension { interface Extension {
id: string; id: string;
name: string; name: string;
description: string; description: string;
icon: any; icon: React.ElementType;
} }
const AVAILABLE_EXTENSIONS: Extension[] = [ const AVAILABLE_EXTENSIONS: Extension[] = [
@ -58,7 +59,9 @@ export default function ExtensionPopover() {
const handleSetActiveExtension = (extension: Extension | null) => { const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => { send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to set active extension: ${resp.error.data || "Unknown error"}`); notifications.error(
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
);
return; return;
} }
setActiveExtension(extension); setActiveExtension(extension);
@ -80,7 +83,7 @@ export default function ExtensionPopover() {
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="space-y-4"> <div className="space-y-4">
{activeExtension ? ( {activeExtension ? (
@ -89,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()} {renderActiveExtension()}
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -110,7 +113,7 @@ export default function ExtensionPopover() {
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />
<Card className="opacity-0 animate-fadeIn"> <Card className="animate-fadeIn opacity-0">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => ( {AVAILABLE_EXTENSIONS.map(extension => (
<div <div

View File

@ -1,11 +1,6 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { import {
LuArrowUpFromLine, LuArrowUpFromLine,
LuCheckCheck, LuCheckCheck,
@ -13,11 +8,17 @@ import {
LuPlus, LuPlus,
LuRadioReceiver, LuRadioReceiver,
} from "react-icons/lu"; } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
@ -194,7 +195,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div ref={ref} className="grid h-full grid-rows-headerBody"> <div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 "> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Virtual Media" title="Virtual Media"

View File

@ -1,15 +1,16 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings"; import { chars, keys, modifiers } from "@/keyboardMappings";
import notifications from "@/notifications";
const hidKeyboardPayload = (keys: number[], modifier: number) => { const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier }; return { keys, modifier };
@ -59,6 +60,7 @@ export default function PasteModal() {
}); });
} }
} catch (error) { } catch (error) {
console.error(error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]); }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
@ -71,7 +73,7 @@ export default function PasteModal() {
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
@ -81,7 +83,7 @@ export default function PasteModal() {
/> />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -120,8 +122,8 @@ export default function PasteModal() {
/> />
{invalidChars.length > 0 && ( {invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2"> <div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" /> <ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400"> <span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "} The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")} {invalidChars.join(", ")}
@ -135,7 +137,7 @@ export default function PasteModal() {
</div> </div>
</div> </div>
<div <div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2" className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -1,8 +1,8 @@
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu"; import { LuPlus, LuArrowLeft } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu"; import { InputFieldWithLabel } from "@/components/InputField";
import { Button } from "@/components/Button";
interface AddDeviceFormProps { interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void; onAddDevice: (name: string, macAddress: string) => void;
@ -26,7 +26,7 @@ export default function AddDeviceForm({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div <div
className="space-y-4 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-4 opacity-0"
style={{ style={{
animationDuration: "0.5s", animationDuration: "0.5s",
animationFillMode: "forwards", animationFillMode: "forwards",
@ -73,7 +73,7 @@ export default function AddDeviceForm({
/> />
</div> </div>
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -1,8 +1,9 @@
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu"; import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import Card from "@/components/Card";
import { FieldError } from "@/components/InputField";
export interface StoredDevice { export interface StoredDevice {
name: string; name: string;
macAddress: string; macAddress: string;
@ -27,12 +28,14 @@ export default function DeviceList({
}: DeviceListProps) { }: DeviceListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="opacity-0 animate-fadeIn"> <Card className="animate-fadeIn opacity-0">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => ( {storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2"> <div key={index} className="flex items-center justify-between gap-x-2 p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p> <p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
{device?.name}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()} {device.macAddress?.toLowerCase()}
</p> </p>
@ -60,18 +63,13 @@ export default function DeviceList({
</div> </div>
</Card> </Card>
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<Button <Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"

View File

@ -1,7 +1,8 @@
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid"; import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import Card from "@/components/Card";
import { Button } from "@/components/Button";
export default function EmptyStateCard({ export default function EmptyStateCard({
onCancelWakeOnLanModal, onCancelWakeOnLanModal,
@ -11,15 +12,15 @@ export default function EmptyStateCard({
setShowAddForm: (show: boolean) => void; setShowAddForm: (show: boolean) => void;
}) { }) {
return ( return (
<div className="space-y-4 select-none"> <div className="select-none space-y-4">
<Card className="opacity-0 animate-fadeIn"> <Card className="animate-fadeIn opacity-0">
<div className="flex items-center justify-center py-8 text-center"> <div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" /> <PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
</div> </div>
</Card> </Card>
</div> </div>
@ -34,7 +35,7 @@ export default function EmptyStateCard({
</div> </div>
</Card> </Card>
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard"; import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList"; import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm"; import AddDeviceForm from "./AddDeviceForm";
@ -99,7 +101,7 @@ export default function WakeOnLanModal() {
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader

View File

@ -1,9 +1,10 @@
import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import SidebarHeader from "@/components/SidebarHeader";
import { GridCard } from "@/components/Card";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@/components/StatChart";
function createChartArray<T, K extends keyof T>( function createChartArray<T, K extends keyof T>(
stream: Map<number, T>, stream: Map<number, T>,
metric: K, metric: K,
@ -120,7 +121,7 @@ export default function ConnectionStatsSidebar() {
<GridCard> <GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> <div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? ( {inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 "> <div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p> <p className="text-slate-700">Waiting for data...</p>
</div> </div>
) : isMetricSupported(inboundRtpStats, "packetsLost") ? ( ) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
@ -130,7 +131,7 @@ export default function ConnectionStatsSidebar() {
unit=" packets" unit=" packets"
/> />
) : ( ) : (
<div className="flex flex-col items-center space-y-1 "> <div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p> <p className="text-black">Metric not supported</p>
</div> </div>
)} )}
@ -149,7 +150,7 @@ export default function ConnectionStatsSidebar() {
<GridCard> <GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> <div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? ( {inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 "> <div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p> <p className="text-slate-700">Waiting for data...</p>
</div> </div>
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? ( ) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
@ -167,7 +168,7 @@ export default function ConnectionStatsSidebar() {
unit=" ms" unit=" ms"
/> />
) : ( ) : (
<div className="flex flex-col items-center space-y-1 "> <div className="flex flex-col items-center space-y-1">
<p className="text-black">Metric not supported</p> <p className="text-black">Metric not supported</p>
</div> </div>
)} )}
@ -186,7 +187,7 @@ export default function ConnectionStatsSidebar() {
<GridCard> <GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> <div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? ( {inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 "> <div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p> <p className="text-slate-700">Waiting for data...</p>
</div> </div>
) : ( ) : (
@ -216,7 +217,7 @@ export default function ConnectionStatsSidebar() {
<GridCard> <GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500"> <div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{inboundRtpStats.size === 0 ? ( {inboundRtpStats.size === 0 ? (
<div className="flex flex-col items-center space-y-1 "> <div className="flex flex-col items-center space-y-1">
<p className="text-slate-700">Waiting for data...</p> <p className="text-slate-700">Waiting for data...</p>
</div> </div>
) : ( ) : (

View File

@ -1,7 +1,8 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom"; import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { isOnDevice } from "../main";
/** /**
* Generates the correct path based on whether the app is running on device or in cloud mode * Generates the correct path based on whether the app is running on device or in cloud mode
* *

View File

@ -1,5 +1,6 @@
import { useContext } from "react"; import { useContext } from "react";
import { FeatureFlagContext } from "../providers/FeatureFlagProvider";
import { FeatureFlagContext } from "@/providers/FeatureFlagContext";
export const useFeatureFlag = (minAppVersion: string) => { export const useFeatureFlag = (minAppVersion: string) => {
const context = useContext(FeatureFlagContext); const context = useContext(FeatureFlagContext);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useRTCStore } from "@/hooks/stores"; import { useRTCStore } from "@/hooks/stores";
export interface JsonRpcRequest { export interface JsonRpcRequest {
@ -56,7 +57,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
// The "API" can also "request" data from the client // The "API" can also "request" data from the client
// If the payload has a method, it's a request // If the payload has a method, it's a request
if ("method" in payload) { if ("method" in payload) {
onRequest && onRequest(payload); if (onRequest) onRequest(payload);
return; return;
} }

View File

@ -1,4 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores"; import { useHidStore, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";

View File

@ -1,19 +1,18 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useIsMounted } from "./useIsMounted"; import { useIsMounted } from "./useIsMounted";
/** The size of the observed element. */ /** The size of the observed element. */
type Size = { interface Size {
/** The width of the observed element. */ /** The width of the observed element. */
width: number | undefined; width: number | undefined;
/** The height of the observed element. */ /** The height of the observed element. */
height: number | undefined; height: number | undefined;
}; }
/** The options for the ResizeObserver. */ /** The options for the ResizeObserver. */
type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = { interface UseResizeObserverOptions<T extends HTMLElement = HTMLElement> {
/** The ref of the element to observe. */ /** The ref of the element to observe. */
ref: RefObject<T>; ref: RefObject<T>;
/** /**
@ -26,7 +25,7 @@ type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
* @default 'content-box' * @default 'content-box'
*/ */
box?: "border-box" | "content-box" | "device-pixel-content-box"; box?: "border-box" | "content-box" | "device-pixel-content-box";
}; }
const initialSize: Size = { const initialSize: Size = {
width: undefined, width: undefined,
@ -102,7 +101,7 @@ export function useResizeObserver<T extends HTMLElement = HTMLElement>(
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [box, ref.current, isMounted]); }, [box, isMounted, ref]);
return { width, height }; return { width, height };
} }
@ -127,6 +126,6 @@ function extractSize(
return Array.isArray(entry[box]) return Array.isArray(entry[box])
? entry[box][0][sizeType] ? entry[box][0][sizeType]
: // @ts-ignore Support Firefox's non-standard behavior : // @ts-expect-error Support Firefox's non-standard behavior
(entry[box][sizeType] as number); (entry[box][sizeType] as number);
} }

View File

@ -1,5 +1,4 @@
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Root from "./root";
import "./index.css"; import "./index.css";
import { import {
createBrowserRouter, createBrowserRouter,
@ -8,19 +7,22 @@ import {
RouterProvider, RouterProvider,
useRouteError, useRouteError,
} from "react-router-dom"; } from "react-router-dom";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login";
import SignupRoute from "@routes/signup";
import AdoptRoute from "@routes/adopt";
import DeviceIdRename from "@routes/devices.$id.rename";
import DevicesIdDeregister from "@routes/devices.$id.deregister";
import NotFoundPage from "@components/NotFoundPage";
import EmptyCard from "@components/EmptyCard";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import EmptyCard from "@components/EmptyCard";
import NotFoundPage from "@components/NotFoundPage";
import DevicesIdDeregister from "@routes/devices.$id.deregister";
import DeviceIdRename from "@routes/devices.$id.rename";
import AdoptRoute from "@routes/adopt";
import SignupRoute from "@routes/signup";
import LoginRoute from "@routes/login";
import SetupRoute from "@routes/devices.$id.setup";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import Card from "@components/Card"; import Card from "@components/Card";
import DevicesAlreadyAdopted from "@routes/devices.already-adopted"; import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Root from "./root";
import Notifications from "./notifications"; import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local"; import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode"; import WelcomeLocalModeRoute from "./routes/welcome-local.mode";

View File

@ -1,8 +1,9 @@
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast"; import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
interface NotificationOptions { interface NotificationOptions {
duration?: number; duration?: number;

View File

@ -0,0 +1,10 @@
import { createContext } from "react";
import { FeatureFlagContextType } from "./FeatureFlagProvider";
// Create the context
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
appVersion: null,
isFeatureEnabled: () => false,
});

View File

@ -1,17 +1,12 @@
import { createContext } from "react";
import semver from "semver"; import semver from "semver";
interface FeatureFlagContextType { import { FeatureFlagContext } from "./FeatureFlagContext";
export interface FeatureFlagContextType {
appVersion: string | null; appVersion: string | null;
isFeatureEnabled: (minVersion: string) => boolean; isFeatureEnabled: (minVersion: string) => boolean;
} }
// Create the context
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
appVersion: null,
isFeatureEnabled: () => false,
});
// Provider component that fetches version and provides context // Provider component that fetches version and provides context
export const FeatureFlagProvider = ({ export const FeatureFlagProvider = ({
children, children,

View File

@ -1,7 +1,9 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom"; import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import api from "../api";
export interface CloudState { export interface CloudState {
connected: boolean; connected: boolean;
url: string; url: string;

View File

@ -6,6 +6,8 @@ import {
useActionData, useActionData,
useLoaderData, useLoaderData,
} from "react-router-dom"; } from "react-router-dom";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader"; import { CardHeader } from "@components/CardHeader";
@ -13,7 +15,6 @@ import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores"; import { User } from "@/hooks/stores";
import { checkAuth } from "@/main"; import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
interface LoaderData { interface LoaderData {
@ -36,6 +37,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
return { message: "There was an error renaming your device. Please try again." }; return { message: "There was an error renaming your device. Please try again." };
} }
} catch (e) { } catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." }; return { message: "There was an error renaming your device. Please try again." };
} }

View File

@ -1,15 +1,4 @@
import Card, { GridCard } from "@/components/Card";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
import { cx } from "../cva.config";
import { import {
LuGlobe, LuGlobe,
LuLink, LuLink,
@ -18,8 +7,15 @@ import {
LuCheck, LuCheck,
LuUpload, LuUpload,
} from "react-icons/lu"; } from "react-icons/lu";
import { PlusCircleIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router-dom";
import Card, { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "@components/AutoHeight"; import AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "@/components/InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import DebianIcon from "@/assets/debian-icon.png"; import DebianIcon from "@/assets/debian-icon.png";
@ -28,14 +24,20 @@ import FedoraIcon from "@/assets/fedora-icon.png";
import OpenSUSEIcon from "@/assets/opensuse-icon.png"; import OpenSUSEIcon from "@/assets/opensuse-icon.png";
import ArchIcon from "@/assets/arch-icon.png"; import ArchIcon from "@/assets/arch-icon.png";
import NetBootIcon from "@/assets/netboot-icon.svg"; import NetBootIcon from "@/assets/netboot-icon.svg";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { isOnDevice } from "../main";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import { useNavigate } from "react-router-dom";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import { cx } from "../cva.config";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
export default function MountRoute() { export default function MountRoute() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -1,11 +1,12 @@
import { useNavigate, useOutletContext } from "react-router-dom"; import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg"; import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg"; import LogoWhite from "@/assets/logo-white.svg";
interface ContextType { interface ContextType {
connectWebRTC: () => Promise<void>; setupPeerConnection: () => Promise<void>;
} }
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
@ -15,7 +16,7 @@ export default function OtherSessionRoute() {
// Function to handle closing the modal // Function to handle closing the modal
const handleClose = () => { const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate("..")); outletContext?.setupPeerConnection().then(() => navigate(".."));
}; };
return ( return (

View File

@ -6,8 +6,9 @@ import {
useActionData, useActionData,
useLoaderData, useLoaderData,
} from "react-router-dom"; } from "react-router-dom";
import { Button, LinkButton } from "@components/Button";
import { ChevronLeftIcon } from "@heroicons/react/16/solid"; import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader"; import { CardHeader } from "@components/CardHeader";
import { InputFieldWithLabel } from "@components/InputField"; import { InputFieldWithLabel } from "@components/InputField";
@ -15,9 +16,10 @@ import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores"; import { User } from "@/hooks/stores";
import { checkAuth } from "@/main"; import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import api from "../api";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
import api from "../api";
interface LoaderData { interface LoaderData {
device: { id: string; name: string; user: { googleId: string } }; device: { id: string; name: string; user: { googleId: string } };
user: User; user: User;
@ -39,6 +41,7 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
return { message: "There was an error renaming your device. Please try again." }; return { message: "There was an error renaming your device. Please try again." };
} }
} catch (e) { } catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." }; return { message: "There was an error renaming your device. Please try again." };
} }
@ -80,9 +83,9 @@ export default function DeviceIdRename() {
picture={user?.picture} picture={user?.picture}
/> />
<div className="w-full h-full"> <div className="h-full w-full">
<div className="mt-4"> <div className="mt-4">
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl"> <div className="mx-auto h-full w-full space-y-6 px-4 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
<div className="space-y-4"> <div className="space-y-4">
<LinkButton <LinkButton
size="SM" size="SM"
@ -100,7 +103,7 @@ export default function DeviceIdRename() {
<Fieldset> <Fieldset>
<Form method="POST" className="max-w-sm space-y-4"> <Form method="POST" className="max-w-sm space-y-4">
<div className="relative group"> <div className="group relative">
<InputFieldWithLabel <InputFieldWithLabel
label="New device name" label="New device name"
type="text" type="text"

View File

@ -1,4 +1,5 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom"; import { LoaderFunctionArgs, redirect } from "react-router-dom";
import { getDeviceUiPath } from "../hooks/useAppNavigation"; import { getDeviceUiPath } from "../hooks/useAppNavigation";
export function loader({ params }: LoaderFunctionArgs) { export function loader({ params }: LoaderFunctionArgs) {

View File

@ -1,20 +1,22 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useLoaderData, useNavigate } from "react-router-dom"; import { useLoaderData, useNavigate } from "react-router-dom";
import { Button, LinkButton } from "../components/Button";
import { DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { GridCard } from "../components/Card";
import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import notifications from "../notifications";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { InputFieldWithLabel } from "../components/InputField"; import api from "@/api";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader"; import { GridCard } from "@/components/Card";
import { isOnDevice } from "../main"; import { Button, LinkButton } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
import { DEVICE_API } from "@/ui.config";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { isOnDevice } from "@/main";
import { LocalDevice } from "./devices.$id";
import { SettingsItem } from "./devices.$id.settings";
import { CloudState } from "./adopt"; import { CloudState } from "./adopt";
export const loader = async () => { export const loader = async () => {

View File

@ -1,9 +1,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLocation, useRevalidator } from "react-router-dom";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api"; import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores"; import { useLocalAuthModalStore } from "@/hooks/stores";
import { useLocation, useRevalidator } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SecurityAccessLocalAuthRoute() { export default function SecurityAccessLocalAuthRoute() {
@ -53,6 +54,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
setError(data.error || "An error occurred while setting the password"); setError(data.error || "An error occurred while setting the password");
} }
} catch (error) { } catch (error) {
console.error(error);
setError("An error occurred while setting the password"); setError("An error occurred while setting the password");
} }
}; };
@ -92,6 +94,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
setError(data.error || "An error occurred while changing the password"); setError(data.error || "An error occurred while changing the password");
} }
} catch (error) { } catch (error) {
console.error(error);
setError("An error occurred while changing the password"); setError("An error occurred while changing the password");
} }
}; };
@ -113,6 +116,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
setError(data.error || "An error occurred while disabling the password"); setError(data.error || "An error occurred while disabling the password");
} }
} catch (error) { } catch (error) {
console.error(error);
setError("An error occurred while disabling the password"); setError("An error occurred while disabling the password");
} }
}; };

View File

@ -1,16 +1,19 @@
import { SettingsItem } from "./devices.$id.settings";
import { useCallback, useState, useEffect } from "react";
import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "../components/SettingsPageheader"; import { SettingsPageHeader } from "../components/SettingsPageheader";
import Checkbox from "../components/Checkbox"; import Checkbox from "../components/Checkbox";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import { useCallback, useState, useEffect } from "react";
import notifications from "../notifications"; import notifications from "../notifications";
import { TextAreaWithLabel } from "../components/TextArea"; import { TextAreaWithLabel } from "../components/TextArea";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { Button } from "../components/Button"; import { Button } from "../components/Button";
import { useSettingsStore } from "../hooks/stores"; import { useSettingsStore } from "../hooks/stores";
import { GridCard } from "@components/Card";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAdvancedRoute() { export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();

View File

@ -1,6 +1,8 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { SettingsPageHeader } from "../components/SettingsPageheader"; import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAppearanceRoute() { export default function SettingsAppearanceRoute() {

View File

@ -1,15 +1,17 @@
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings"; import { useState , useEffect } from "react";
import { useState } from "react";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { Button } from "../components/Button"; import { Button } from "../components/Button";
import notifications from "../notifications"; import notifications from "../notifications";
import Checkbox from "../components/Checkbox"; import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { useDeviceStore } from "../hooks/stores"; import { useDeviceStore } from "../hooks/stores";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsGeneralRoute() { export default function SettingsGeneralRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();

View File

@ -1,11 +1,12 @@
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import Card from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores"; import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";

View File

@ -1,13 +1,14 @@
import { useEffect } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings"; import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import notifications from "../notifications"; import notifications from "../notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { FeatureFlag } from "../components/FeatureFlag"; import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() { export default function SettingsHardwareRoute() {

View File

@ -1,3 +1,6 @@
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react";
import MouseIcon from "@/assets/mouse-icon.svg"; import MouseIcon from "@/assets/mouse-icon.svg";
import PointingFinger from "@/assets/pointing-finger.svg"; import PointingFinger from "@/assets/pointing-finger.svg";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
@ -6,11 +9,11 @@ import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react";
import { FeatureFlag } from "../components/FeatureFlag"; import { FeatureFlag } from "../components/FeatureFlag";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { useFeatureFlag } from "../hooks/useFeatureFlag"; import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
type ScrollSensitivity = "low" | "default" | "high"; type ScrollSensitivity = "low" | "default" | "high";

View File

@ -1,5 +1,4 @@
import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import Card from "@/components/Card";
import { import {
LuSettings, LuSettings,
LuKeyboard, LuKeyboard,
@ -10,8 +9,11 @@ import {
LuArrowLeft, LuArrowLeft,
LuPalette, LuPalette,
} from "react-icons/lu"; } from "react-icons/lu";
import { LinkButton } from "../components/Button";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Card from "@/components/Card";
import { LinkButton } from "../components/Button";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores"; import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard"; import useKeyboard from "../hooks/useKeyboard";

View File

@ -1,12 +1,14 @@
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { useState, useEffect } from "react";
import { SettingsItem } from "./devices.$id.settings";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea"; import { TextAreaWithLabel } from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useState, useEffect } from "react"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "../notifications"; import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
const defaultEdid = const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [ const edids = [

View File

@ -1,8 +1,3 @@
import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import StepCounter from "@components/StepCounter";
import Fieldset from "@components/Fieldset";
import { import {
ActionFunctionArgs, ActionFunctionArgs,
Form, Form,
@ -12,12 +7,19 @@ import {
useParams, useParams,
useSearchParams, useSearchParams,
} from "react-router-dom"; } from "react-router-dom";
import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import StepCounter from "@components/StepCounter";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField"; import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { checkAuth } from "@/main"; import { checkAuth } from "@/main";
import api from "../api";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
import api from "../api";
const loader = async ({ params }: LoaderFunctionArgs) => { const loader = async ({ params }: LoaderFunctionArgs) => {
await checkAuth(); await checkAuth();
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, { const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {

View File

@ -1,4 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LoaderFunctionArgs,
Outlet,
Params,
redirect,
useLoaderData,
useLocation,
useNavigate,
useOutlet,
useParams,
useSearchParams,
} from "react-router-dom";
import { useInterval } from "usehooks-ts";
import FocusTrap from "focus-trap-react";
import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
DeviceSettingsState, DeviceSettingsState,
@ -16,36 +33,28 @@ import {
VideoState, VideoState,
} from "@/hooks/stores"; } from "@/hooks/stores";
import WebRTCVideo from "@components/WebRTCVideo"; import WebRTCVideo from "@components/WebRTCVideo";
import {
LoaderFunctionArgs,
Outlet,
Params,
redirect,
useLoaderData,
useLocation,
useNavigate,
useOutlet,
useParams,
useSearchParams,
} from "react-router-dom";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react";
import Terminal from "@components/Terminal"; import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay,
} from "../components/VideoOverlay";
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider"; import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import notifications from "../notifications"; import notifications from "../notifications";
import { DeviceStatus } from "./welcome-local";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
} }
@ -110,7 +119,6 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
export default function KvmIdRoute() { export default function KvmIdRoute() {
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp; const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
// Depending on the mode, we set the appropriate variables // Depending on the mode, we set the appropriate variables
const user = "user" in loaderResp ? loaderResp.user : null; const user = "user" in loaderResp ? loaderResp.user : null;
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null; const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
@ -123,84 +131,347 @@ export default function KvmIdRoute() {
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream); const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection); const setPeerConnection = useRTCStore(state => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel); const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore(state => state.setTransceiver);
const location = useLocation();
const isLegacySignalingEnabled = useRef(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore(); const { otaState, setOtaState, setModalView } = useUpdateStore();
const sdp = useCallback( const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { const cleanupAndStopReconnecting = useCallback(
if (!pc) return; function cleanupAndStopReconnecting() {
if (event.candidate !== null) return; console.log("Closing peer connection");
try { setConnectionFailed(true);
const sd = btoa(JSON.stringify(pc.localDescription)); if (peerConnection) {
setPeerConnectionState(peerConnection.connectionState);
const sessionUrl = isOnDevice
? `${DEVICE_API}/webrtc/session`
: `${CLOUD_API}/webrtc/session`;
const res = await api.POST(sessionUrl, {
sd,
// When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }),
});
const json = await res.json();
if (isOnDevice) {
if (res.status === 401) {
return navigate("/login-local");
}
}
if (isInCloud) {
// The cloud API returns a 401 if the user is not logged in
// Most likely the session has expired
if (res.status === 401) return navigate("/login");
// If can be a few things
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
if (!res.ok) {
pc?.close();
console.error(`Error setting SDP - Status: ${res.status}}`, json);
return;
}
}
pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(atob(json.sd))),
).catch(e => console.log(`Error setting remote description: ${e}`));
} catch (error) {
console.error(`Error setting SDP: ${error}`);
pc?.close();
} }
connectionFailedRef.current = true;
peerConnection?.close();
signalingAttempts.current = 0;
}, },
[navigate, params.id], [peerConnection, setPeerConnectionState],
); );
const connectWebRTC = useCallback(async () => { // We need to track connectionFailed in a ref to avoid stale closure issues
console.log("Attempting to connect WebRTC"); // This is necessary because syncRemoteSessionDescription is a callback that captures
const pc = new RTCPeerConnection({ // the connectionFailed value at creation time, but we need the latest value
// We only use STUN or TURN servers if we're in the cloud // when the function is actually called. Without this ref, the function would use
...(isInCloud && iceConfig?.iceServers // a stale value of connectionFailed in some conditions.
? { iceServers: [iceConfig?.iceServers] } //
: {}), // We still need the state variable for UI rendering, so we sync the ref with the state.
}); // This pattern is a workaround for what useEvent hook would solve more elegantly
// (which would give us a callback that always has access to latest state without re-creation).
const connectionFailedRef = useRef(false);
useEffect(() => {
connectionFailedRef.current = connectionFailed;
}, [connectionFailed]);
const signalingAttempts = useRef(0);
const setRemoteSessionDescription = useCallback(
async function setRemoteSessionDescription(
pc: RTCPeerConnection,
remoteDescription: RTCSessionDescriptionInit,
) {
setLoadingMessage("Setting remote description");
try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully");
setLoadingMessage("Establishing secure connection...");
} catch (error) {
console.error(
"[setRemoteSessionDescription] Failed to set remote description:",
error,
);
cleanupAndStopReconnecting();
return;
}
// Replace the interval-based check with a more reliable approach
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
if (pc.sctp?.state === "connected") {
console.log("[setRemoteSessionDescription] Remote description set");
clearInterval(checkInterval);
setLoadingMessage("Connection established");
} else if (attempts >= 10) {
console.log(
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
{
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
},
);
cleanupAndStopReconnecting();
clearInterval(checkInterval);
} else {
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
});
}
}, 1000);
},
[cleanupAndStopReconnecting],
);
const ignoreOffer = useRef(false);
const isSettingRemoteAnswerPending = useRef(false);
const makingOffer = useRef(false);
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { sendMessage, getWebSocket } = useWebSocket(
isOnDevice
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
: `${CLOUD_API.replace("http", "ws")}/webrtc/signaling/client?id=${params.id}`,
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.log("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.log("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
},
onClose(event) {
console.log("[Websocket] onClose", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
console.log("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.log("[Websocket] onOpen");
},
onMessage: message => {
if (message.data === "pong") return;
/*
Currently the signaling process is as follows:
After open, the other side will send a `device-metadata` message with the device version
If the device version is not set, we can assume the device is using the legacy signaling
Otherwise, we can assume the device is using the new signaling
If the device is using the legacy signaling, we close the websocket connection
and use the legacy HTTPSignaling function to get the remote session description
If the device is using the new signaling, we don't need to do anything special, but continue to use the websocket connection
to chat with the other peer about the connection
*/
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.log("[Websocket] Received device-metadata message");
console.log("[Websocket] Device version", deviceVersion);
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
console.log("[Websocket] Device is using legacy signaling");
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
// which does everything over HTTP(at least from the perspective of the client)
isLegacySignalingEnabled.current = true;
getWebSocket()?.close();
} else {
console.log("[Websocket] Device is using new signaling");
isLegacySignalingEnabled.current = false;
}
setupPeerConnection();
}
if (!peerConnection) return;
if (parsedMessage.type === "answer") {
console.log("[Websocket] Received answer");
const readyForOffer =
// If we're making an offer, we don't want to accept an answer
!makingOffer &&
// If the peer connection is stable or we're setting the remote answer pending, we're ready for an offer
(peerConnection?.signalingState === "stable" ||
isSettingRemoteAnswerPending.current);
// If we're not ready for an offer, we don't want to accept an offer
ignoreOffer.current = parsedMessage.type === "offer" && !readyForOffer;
if (ignoreOffer.current) return;
// Set so we don't accept an answer while we're setting the remote description
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
console.log(
"[Websocket] Setting remote answer pending",
isSettingRemoteAnswerPending.current,
);
const sd = atob(parsedMessage.data);
const remoteSessionDescription = JSON.parse(sd);
setRemoteSessionDescription(
peerConnection,
new RTCSessionDescription(remoteSessionDescription),
);
// Reset the remote answer pending flag
isSettingRemoteAnswerPending.current = false;
} else if (parsedMessage.type === "new-ice-candidate") {
console.log("[Websocket] Received new-ice-candidate");
const candidate = parsedMessage.data;
peerConnection.addIceCandidate(candidate);
}
},
},
// Don't even retry once we declare failure
!connectionFailed && isLegacySignalingEnabled.current === false,
);
const sendWebRTCSignal = useCallback(
(type: string, data: unknown) => {
// Second argument tells the library not to queue the message, and send it once the connection is established again.
// We have event handlers that handle the connection set up, so we don't need to queue the message.
sendMessage(JSON.stringify({ type, data }), false);
},
[sendMessage],
);
const legacyHTTPSignaling = useCallback(
async (pc: RTCPeerConnection) => {
const sd = btoa(JSON.stringify(pc.localDescription));
// Legacy mode == UI in cloud with updated code connecting to older device version.
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
const sessionUrl = `${CLOUD_API}/webrtc/session`;
console.log("Trying to get remote session description");
setLoadingMessage(
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
);
const res = await api.POST(sessionUrl, {
sd,
// When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }),
});
const json = await res.json();
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
if (!res.ok) {
console.error("Error getting SDP", { status: res.status, json });
cleanupAndStopReconnecting();
return;
}
console.log("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
const decodedSd = atob(json.sd);
const parsedSd = JSON.parse(decodedSd);
setRemoteSessionDescription(pc, new RTCSessionDescription(parsedSd));
},
[cleanupAndStopReconnecting, navigate, params.id, setRemoteSessionDescription],
);
const setupPeerConnection = useCallback(async () => {
console.log("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
let pc: RTCPeerConnection;
try {
console.log("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {}),
});
setPeerConnectionState(pc.connectionState);
console.log("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
setTimeout(() => {
cleanupAndStopReconnecting();
}, 1000);
return;
}
// Set up event listeners and data channels // Set up event listeners and data channels
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
setPeerConnectionState(pc.connectionState); setPeerConnectionState(pc.connectionState);
}; };
pc.onicecandidate = event => sdp(event, pc); pc.onnegotiationneeded = async () => {
try {
console.log("[setupPeerConnection] Creating offer");
makingOffer.current = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sd = btoa(JSON.stringify(pc.localDescription));
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
if (isNewSignalingEnabled) {
sendWebRTCSignal("offer", { sd: sd });
} else {
console.log("Legacy signanling. Waiting for ICE Gathering to complete...");
}
} catch (e) {
console.error(
`[setupPeerConnection] Error creating offer: ${e}`,
new Date().toISOString(),
);
cleanupAndStopReconnecting();
} finally {
makingOffer.current = false;
}
};
pc.onicecandidate = async ({ candidate }) => {
if (!candidate) return;
if (candidate.candidate === "") return;
sendWebRTCSignal("new-ice-candidate", candidate);
};
pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.log("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
if (isLegacySignalingEnabled.current) {
// We can now start the https/ws connection to get the remote session description from the KVM device
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
pc.ontrack = function (event) { pc.ontrack = function (event) {
setMediaMediaStream(event.streams[0]); setMediaMediaStream(event.streams[0]);
@ -218,16 +489,12 @@ export default function KvmIdRoute() {
setDiskChannel(diskDataChannel); setDiskChannel(diskDataChannel);
}; };
try { setPeerConnection(pc);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
setPeerConnection(pc);
} catch (e) {
console.error(`Error creating offer: ${e}`);
}
}, [ }, [
cleanupAndStopReconnecting,
iceConfig?.iceServers, iceConfig?.iceServers,
sdp, legacyHTTPSignaling,
sendWebRTCSignal,
setDiskChannel, setDiskChannel,
setMediaMediaStream, setMediaMediaStream,
setPeerConnection, setPeerConnection,
@ -236,23 +503,12 @@ export default function KvmIdRoute() {
setTransceiver, setTransceiver,
]); ]);
// WebRTC connection management
useInterval(() => {
if (
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
) {
return;
}
if (location.pathname.includes("other-session")) return;
connectWebRTC();
}, 3000);
// On boot, if the connection state is undefined, we connect to the WebRTC
useEffect(() => { useEffect(() => {
if (peerConnection?.connectionState === undefined) { if (peerConnectionState === "failed") {
connectWebRTC(); console.log("Connection failed, closing peer connection");
cleanupAndStopReconnecting();
} }
}, [connectWebRTC, peerConnection?.connectionState]); }, [peerConnectionState, cleanupAndStopReconnecting]);
// Cleanup effect // Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
@ -277,7 +533,7 @@ export default function KvmIdRoute() {
// TURN server usage detection // TURN server usage detection
useEffect(() => { useEffect(() => {
if (peerConnection?.connectionState !== "connected") return; if (peerConnectionState !== "connected") return;
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState(); const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
const lastLocalStat = Array.from(localCandidateStats).pop(); const lastLocalStat = Array.from(localCandidateStats).pop();
@ -289,7 +545,7 @@ export default function KvmIdRoute() {
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
}, [peerConnection?.connectionState, setIsTurnServerInUse]); }, [peerConnectionState, setIsTurnServerInUse]);
// TURN server usage reporting // TURN server usage reporting
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@ -380,10 +636,6 @@ export default function KvmIdRoute() {
}); });
}, [rpcDataChannel?.readyState, send, setHdmiState]); }, [rpcDataChannel?.readyState, send, setHdmiState]);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.send = send;
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
if (queryParams.get("updateSuccess")) { if (queryParams.get("updateSuccess")) {
@ -420,18 +672,17 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (!peerConnection) return; if (!peerConnection) return;
if (!kvmTerminal) { if (!kvmTerminal) {
console.log('Creating data channel "terminal"'); // console.log('Creating data channel "terminal"');
setKvmTerminal(peerConnection.createDataChannel("terminal")); setKvmTerminal(peerConnection.createDataChannel("terminal"));
} }
if (!serialConsole) { if (!serialConsole) {
console.log('Creating data channel "serial"'); // console.log('Creating data channel "serial"');
setSerialConsole(peerConnection.createDataChannel("serial")); setSerialConsole(peerConnection.createDataChannel("serial"));
} }
}, [kvmTerminal, peerConnection, serialConsole]); }, [kvmTerminal, peerConnection, serialConsole]);
const outlet = useOutlet(); const outlet = useOutlet();
const location = useLocation();
const onModalClose = useCallback(() => { const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") navigateTo("/"); if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]); }, [navigateTo, location.pathname]);
@ -469,6 +720,43 @@ export default function KvmIdRoute() {
[send, setScrollSensitivity], [send, setScrollSensitivity],
); );
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState || "");
const isPeerConnectionLoading =
["connecting", "new"].includes(peerConnectionState || "") ||
peerConnection === null;
const isDisconnected = peerConnectionState === "disconnected";
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
if (peerConnectionState === "connected") return null;
if (isDisconnected) {
return <PeerConnectionDisconnectedOverlay show={true} />;
}
if (hasConnectionFailed)
return (
<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />
);
if (isPeerConnectionLoading) {
return <LoadingConnectionOverlay show={true} text={loadingMessage} />;
}
return null;
}, [
connectionFailed,
loadingMessage,
location.pathname,
peerConnection,
peerConnectionState,
setupPeerConnection,
]);
return ( return (
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && ( {!outlet && otaState.updating && (
@ -497,6 +785,7 @@ export default function KvmIdRoute() {
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" /> <button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
</div> </div>
</FocusTrap> </FocusTrap>
<div className="grid h-full select-none grid-rows-headerBody"> <div className="grid h-full select-none grid-rows-headerBody">
<DashboardNavbar <DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
@ -507,15 +796,23 @@ export default function KvmIdRoute() {
kvmName={deviceName || "JetKVM Device"} kvmName={deviceName || "JetKVM Device"}
/> />
<div className="flex h-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo /> <WebRTCVideo />
<div
style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4 opacity-0"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
<SidebarContainer sidebarView={sidebarView} /> <SidebarContainer sidebarView={sidebarView} />
</div> </div>
</div> </div>
</div> </div>
<div <div
className="isolate" className="z-50"
onKeyUp={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}
onKeyDown={e => { onKeyDown={e => {
e.stopPropagation(); e.stopPropagation();
@ -523,7 +820,8 @@ export default function KvmIdRoute() {
}} }}
> >
<Modal open={outlet !== null} onClose={onModalClose}> <Modal open={outlet !== null} onClose={onModalClose}>
<Outlet context={{ connectWebRTC }} /> {/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ setupPeerConnection }} />
</Modal> </Modal>
</div> </div>

View File

@ -1,4 +1,6 @@
import { useLoaderData, useRevalidator } from "react-router-dom"; import { useLoaderData, useRevalidator } from "react-router-dom";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { LinkButton } from "@components/Button"; import { LinkButton } from "@components/Button";
@ -7,8 +9,6 @@ import useInterval from "@/hooks/useInterval";
import { checkAuth } from "@/main"; import { checkAuth } from "@/main";
import { User } from "@/hooks/stores"; import { User } from "@/hooks/stores";
import EmptyCard from "@components/EmptyCard"; import EmptyCard from "@components/EmptyCard";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
interface LoaderData { interface LoaderData {
@ -49,8 +49,8 @@ export default function DevicesRoute() {
/> />
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl"> <div className="mx-auto h-full w-full space-y-6 px-4 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
<div className="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20"> <div className="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20">
<div> <div>
<h1 className="text-xl font-bold text-black dark:text-white"> <h1 className="text-xl font-bold text-black dark:text-white">
Cloud KVMs Cloud KVMs

View File

@ -1,19 +1,22 @@
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import SimpleNavbar from "@components/SimpleNavbar"; import SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField"; import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import ExtLink from "../components/ExtLink";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import api from "../api";
import ExtLink from "../components/ExtLink";
import { DeviceStatus } from "./welcome-local";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${DEVICE_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
@ -31,12 +34,9 @@ const action = async ({ request }: ActionFunctionArgs) => {
const password = formData.get("password"); const password = formData.get("password");
try { try {
const response = await api.POST( const response = await api.POST(`${DEVICE_API}/auth/login-local`, {
`${DEVICE_API}/auth/login-local`, password,
{ });
password,
},
);
if (response.ok) { if (response.ok) {
return redirect("/"); return redirect("/");
@ -44,6 +44,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
return { error: "Invalid password" }; return { error: "Invalid password" };
} }
} catch (error) { } catch (error) {
console.error(error);
return { error: "An error occurred while logging in" }; return { error: "An error occurred while logging in" };
} }
}; };
@ -58,22 +59,28 @@ export default function LoginLocalRoute() {
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar /> <SimpleNavbar />
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="isolate flex h-full w-full items-center justify-center">
<div className="max-w-2xl -mt-32 space-y-8"> <div className="-mt-32 max-w-2xl space-y-8">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" /> <img
src={LogoWhiteIcon}
alt=""
className="-ml-4 hidden h-[32px] dark:block"
/>
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" /> <img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Welcome back to JetKVM</h1> <h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome back to JetKVM
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Enter your password to access your JetKVM. Enter your password to access your JetKVM.
</p> </p>
</div> </div>
<Fieldset className="space-y-12"> <Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4"> <Form method="POST" className="mx-auto max-w-sm space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<InputFieldWithLabel <InputFieldWithLabel
label="Password" label="Password"
@ -88,14 +95,14 @@ export default function LoginLocalRoute() {
onClick={() => setShowPassword(false)} onClick={() => setShowPassword(false)}
className="pointer-events-auto" className="pointer-events-auto"
> >
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" /> <LuEye className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div> </div>
) : ( ) : (
<div <div
onClick={() => setShowPassword(true)} onClick={() => setShowPassword(true)}
className="pointer-events-auto" className="pointer-events-auto"
> >
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" /> <LuEyeOff className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div> </div>
) )
} }
@ -111,7 +118,7 @@ export default function LoginLocalRoute() {
textAlign="center" textAlign="center"
/> />
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400"> <div className="mt-4 flex justify-start text-xs text-slate-500 dark:text-slate-400">
<ExtLink <ExtLink
href="https://jetkvm.com/docs/networking/local-access#reset-password" href="https://jetkvm.com/docs/networking/local-access#reset-password"
className="hover:underline" className="hover:underline"

View File

@ -1,6 +1,7 @@
import AuthLayout from "@components/AuthLayout";
import { useLocation, useSearchParams } from "react-router-dom"; import { useLocation, useSearchParams } from "react-router-dom";
import AuthLayout from "@components/AuthLayout";
export default function LoginRoute() { export default function LoginRoute() {
const [sq] = useSearchParams(); const [sq] = useSearchParams();
const location = useLocation(); const location = useLocation();

View File

@ -1,6 +1,7 @@
import AuthLayout from "@components/AuthLayout";
import { useLocation, useSearchParams } from "react-router-dom"; import { useLocation, useSearchParams } from "react-router-dom";
import AuthLayout from "@components/AuthLayout";
export default function SignupRoute() { export default function SignupRoute() {
const [sq] = useSearchParams(); const [sq] = useSearchParams();
const location = useLocation(); const location = useLocation();

Some files were not shown because too many files have changed in this diff Show More