Compare commits

...

10 Commits

Author SHA1 Message Date
Qishuai Liu 9d1b020661
Merge 9d12dd1e54 into 4e90883bf8 2025-05-20 12:30:26 -06:00
Siyuan Miao 4e90883bf8 build: enable trimpath for both dev and prod releases 2025-05-20 20:28:40 +02:00
Adam Shiervani 8eaa86ae45
style(ui): update styling for access and network settings components (#487)
* style(ui): update styling for access and network settings components

* fix(ui): simplify conditional rendering in network settings component
2025-05-20 20:26:24 +02:00
Siyuan Miao 354941b54d build: add trimpath to go build command 2025-05-20 20:18:21 +02:00
Aveline 4b91c758fa
chore: upgrade golang to 1.24.3 and nodejs to 22.x (#483) 2025-05-20 19:08:10 +02:00
Adam Shiervani 222a8470a5
refactor: network settings UI (#486)
* feat(ui): update prettier configuration and quote styles

- Add cx to tailwind functions
- Set tailwind stylesheet path
- Convert single quotes to double quotes in CSS
- Add prettier ignore comments for animation utilities

* refactor(ui): extract network information into separate components

- Create DhcpLeaseCard component
- Create Ipv6NetworkCard component

* style(ui): refine component styling and layout

- Add padding to AutoHeight component
- Improve lifetime label display format
- Enhance network information card layouts

* style(ui): enhance checkbox and radio button styling

- Update Checkbox component to use form-checkbox class
- Refactor radio button classes for consistency across components

* style(ui): Add opacity for fade-in animations

* refactor(ui): enhance Modal and network settings components

- Add stable scrollbar gutter to Modal component
- Refactor custom domain input handling and layout adjustments
2025-05-20 15:22:08 +02:00
Aveline 860327bfcd
chore: always return local version if update check fails (#485) 2025-05-20 14:57:57 +02:00
Qishuai Liu 9d12dd1e54
fix: audio rtp timestamp 2025-05-16 23:11:22 +09:00
Qishuai Liu cc83e4193f
feat: add audio encoder 2025-05-14 23:41:48 +09:00
Qishuai Liu 466271d935
feat: add usb gadget audio config 2025-05-14 23:15:45 +09:00
37 changed files with 706 additions and 448 deletions

View File

@ -19,13 +19,13 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
node-version: "22"
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: Set up Golang
uses: actions/setup-go@v4
with:
go-version: "1.24.0"
go-version: "1.24.3"
- name: Build frontend
run: |
make frontend

View File

@ -26,7 +26,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: 1.24.x
go-version: 1.24.3
- name: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep

View File

@ -106,7 +106,7 @@ jobs:
- name: Set up Golang
uses: actions/setup-go@v4
with:
go-version: "1.24.0"
go-version: "1.24.3"
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:

View File

@ -25,7 +25,10 @@ hash_resource:
build_dev: hash_resource
@echo "Building..."
$(GO_CMD) build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o $(BIN_DIR)/jetkvm_app cmd/main.go
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
-trimpath \
-o $(BIN_DIR)/jetkvm_app cmd/main.go
build_test2json:
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
@ -66,7 +69,10 @@ dev_release: frontend build_dev
build_release: frontend hash_resource
@echo "Building release..."
$(GO_CMD) build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
-trimpath \
-o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

81
audio.go Normal file
View File

@ -0,0 +1,81 @@
package kvm
import (
"fmt"
"net"
"os/exec"
"sync"
"syscall"
"time"
)
func startFFmpeg() (cmd *exec.Cmd, err error) {
binaryPath := "/userdata/jetkvm/bin/ffmpeg"
// Run the binary in the background
cmd = exec.Command(binaryPath,
"-f", "alsa",
"-channels", "2",
"-sample_rate", "48000",
"-i", "hw:1,0",
"-c:a", "libopus",
"-b:a", "64k", // ought to be enough for anybody
"-vbr", "off",
"-frame_duration", "20",
"-compression_level", "2",
"-f", "rtp",
"rtp://127.0.0.1:3333")
nativeOutputLock := sync.Mutex{}
nativeStdout := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stdout"),
}
nativeStderr := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = nativeStdout
cmd.Stderr = nativeStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start binary: %w", err)
}
return
}
func StartNtpAudioServer(handleClient func(net.Conn)) {
scopedLogger := nativeLogger.With().
Logger()
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 3333})
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
return
}
scopedLogger.Info().Msg("server listening")
go func() {
for {
cmd, err := startFFmpeg()
if err != nil {
scopedLogger.Error().Err(err).Msg("failed to start ffmpeg")
}
err = cmd.Wait()
scopedLogger.Error().Err(err).Msg("ffmpeg exited, restarting")
time.Sleep(2 * time.Second)
}
}()
go handleClient(listener)
}

View File

@ -125,6 +125,7 @@ var defaultConfig = &Config{
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
},
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",

View File

@ -59,6 +59,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
// mass storage
"mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config,
// audio
"audio": {
order: 4000,
device: "uac1.usb0",
path: []string{"functions", "uac1.usb0"},
configPath: []string{"uac1.usb0"},
attrs: gadgetAttributes{
"p_chmask": "3",
"p_srate": "48000",
"p_ssize": "2",
"p_volume_present": "0",
"c_chmask": "3",
"c_srate": "48000",
"c_ssize": "2",
"c_volume_present": "0",
},
},
}
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
@ -73,6 +90,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
case "audio":
return u.enabledDevices.Audio
default:
return true
}

View File

@ -18,6 +18,7 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"`
Audio bool `json:"audio"`
}
// Config is a struct that represents the customizations for a USB gadget.

View File

@ -266,8 +266,13 @@ func rpcSetDevChannelState(enabled bool) error {
func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
return updateStatus, nil

View File

@ -76,6 +76,7 @@ func Main() {
}()
initUsbGadget()
StartNtpAudioServer(handleAudioClient)
if err := setInitialVirtualMediaState(); err != nil {
logger.Warn().Err(err).Msg("failed to set initial virtual media state")

View File

@ -12,6 +12,7 @@ import (
"time"
"github.com/jetkvm/kvm/resource"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4/pkg/media"
)
@ -215,7 +216,7 @@ func handleVideoClient(conn net.Conn) {
scopedLogger.Info().Msg("native video socket client connected")
inboundPacket := make([]byte, maxFrameSize)
inboundPacket := make([]byte, maxVideoFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
@ -235,6 +236,44 @@ func handleVideoClient(conn net.Conn) {
}
}
func handleAudioClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("type", "audio").
Logger()
scopedLogger.Info().Msg("native audio socket client connected")
inboundPacket := make([]byte, maxAudioFrameSize)
var timestamp uint32
var packet rtp.Packet
for {
n, err := conn.Read(inboundPacket)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error during read")
return
}
if currentSession != nil {
if err := packet.Unmarshal(inboundPacket[:n]); err != nil {
scopedLogger.Warn().Err(err).Msg("error unmarshalling audio socket packet")
continue
}
timestamp += 960
packet.Header.Timestamp = timestamp
buf, err := packet.Marshal()
if err != nil {
scopedLogger.Warn().Err(err).Msg("error marshalling packet")
continue
}
if _, err := currentSession.AudioTrack.Write(buf); err != nil {
scopedLogger.Warn().Err(err).Msg("error writing sample")
}
}
}
}
func ExtractAndRunNativeBin() error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil {

40
ota.go
View File

@ -41,6 +41,9 @@ type UpdateStatus struct {
Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
// for backwards compatibility
Error string `json:"error,omitempty"`
}
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
@ -489,52 +492,47 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
}
func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) {
updateStatus := &UpdateStatus{}
// Get local versions
systemVersionLocal, appVersionLocal, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
return updateStatus, fmt.Errorf("error getting local version: %w", err)
}
updateStatus.Local = &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
}
// Get remote metadata
remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease)
if err != nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
// Build local UpdateMetadata
localMetadata := &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
return updateStatus, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Remote = remoteMetadata
// Get remote versions
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
if err != nil {
return nil, fmt.Errorf("error parsing remote system version: %w", err)
return updateStatus, fmt.Errorf("error parsing remote system version: %w", err)
}
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil {
return nil, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
}
systemUpdateAvailable := systemVersionRemote.GreaterThan(systemVersionLocal)
appUpdateAvailable := appVersionRemote.GreaterThan(appVersionLocal)
updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal)
updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal)
// Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease {
systemUpdateAvailable = false
updateStatus.SystemUpdateAvailable = false
}
if isRemoteAppPreRelease && !includePreRelease {
appUpdateAvailable = false
}
updateStatus := &UpdateStatus{
Local: localMetadata,
Remote: remoteMetadata,
SystemUpdateAvailable: systemUpdateAvailable,
AppUpdateAvailable: appUpdateAvailable,
updateStatus.AppUpdateAvailable = false
}
return updateStatus, nil

View File

@ -6,6 +6,7 @@
"arrowParens": "avoid",
"singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx"],
"printWidth": 90
"tailwindFunctions": ["clsx", "cx"],
"printWidth": 90,
"tailwindStylesheet": "./src/index.css"
}

View File

@ -22,7 +22,7 @@ const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
{...props}
height={height}
duration={300}
contentClassName="h-fit"
contentClassName="h-fit p-px"
contentRef={contentDiv}
disableDisplayNone
>

View File

@ -12,7 +12,7 @@ const sizes = {
const checkboxVariants = cva({
base: cx(
"block rounded",
"form-checkbox block rounded",
// Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 checked:accent-blue-700 checked:dark:accent-blue-500 transition-colors",

View File

@ -0,0 +1,212 @@
import { LuRefreshCcw } from "react-icons/lu";
import { Button } from "@/components/Button";
import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores";
export default function DhcpLeaseCard({
networkState,
setShowRenewLeaseConfirm,
}: {
networkState: NetworkState;
setShowRenewLeaseConfirm: (show: boolean) => void;
}) {
return (
<GridCard>
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
</span>
</div>
)}
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
</span>
</div>
)}
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
</span>
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
</span>
</div>
)}
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
</span>
</div>
)}
{networkState?.dhcp_lease?.ntp_servers &&
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
<div key={server}>{server}</div>
))}
</div>
</div>
)}
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
</span>
</div>
)}
</div>
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.routers &&
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
<div key={router}>{router}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
</span>
</div>
)}
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
/>
</span>
</div>
)}
{networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">MTU</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.mtu}
</span>
</div>
)}
{networkState?.dhcp_lease?.ttl && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">TTL</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ttl}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
</span>
</div>
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -0,0 +1,93 @@
import { NetworkState } from "../hooks/stores";
import { LifeTimeLabel } from "../routes/devices.$id.settings.network";
import { GridCard } from "./Card";
export default function Ipv6NetworkCard({
networkState,
}: {
networkState: NetworkState;
}) {
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}
</span>
</div>
)}
</div>
<div className="space-y-3 pt-2">
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
{networkState.ipv6_addresses.map(
addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
</span>
<span className="text-sm font-medium">{addr.address}</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
)}
</span>
</div>
)}
</div>
</div>
),
)}
</div>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -20,7 +20,9 @@ const Modal = React.memo(function Modal({
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"
/>
<div className="fixed inset-0 z-20 w-screen overflow-y-auto">
<div className="fixed inset-0 z-20 w-screen overflow-y-auto" style={{
scrollbarGutter: 'stable'
}}>
{/* 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">
<DialogPanel

View File

@ -683,7 +683,7 @@ export default function WebRTCVideo() {
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={true}
muted={false}
playsInline
disablePictureInPicture
controlsList="nofullscreen"

View File

@ -107,7 +107,7 @@ export function ATXPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[120px] animate-fadeIn">
<Card className="h-[120px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Control Buttons */}
<div className="flex items-center space-x-2">

View File

@ -63,7 +63,7 @@ export function DCPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[160px] animate-fadeIn">
<Card className="h-[160px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Power Controls */}
<div className="flex items-center space-x-2">

View File

@ -58,7 +58,7 @@ export function SerialConsole() {
description="Configure your serial console settings"
/>
<Card className="animate-fadeIn">
<Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Open Console Button */}
<div className="flex items-center">

View File

@ -92,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()}
<div
className="flex animate-fadeIn items-center justify-end space-x-2"
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -113,7 +113,7 @@ export default function ExtensionPopover() {
title="Extensions"
description="Load and manage your extensions"
/>
<Card className="animate-fadeIn">
<Card className="animate-fadeIn opacity-0" >
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => (
<div

View File

@ -214,7 +214,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
) : null}
<div
className="animate-fadeIn space-y-2"
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -289,7 +289,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
{!remoteVirtualMediaState && (
<div
className="flex animate-fadeIn items-center justify-end space-x-2"
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -83,7 +83,7 @@ export default function PasteModal() {
/>
<div
className="animate-fadeIn space-y-2"
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -137,7 +137,7 @@ export default function PasteModal() {
</div>
</div>
<div
className="flex animate-fadeIn items-center justify-end gap-x-2"
className="flex animate-fadeIn opacity-0 items-center justify-end gap-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -26,7 +26,7 @@ export default function AddDeviceForm({
return (
<div className="space-y-4">
<div
className="animate-fadeIn space-y-4"
className="animate-fadeIn opacity-0 space-y-4"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
@ -73,7 +73,7 @@ export default function AddDeviceForm({
/>
</div>
<div
className="flex animate-fadeIn items-center justify-end space-x-2"
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -28,7 +28,7 @@ export default function DeviceList({
}: DeviceListProps) {
return (
<div className="space-y-4">
<Card className="animate-fadeIn">
<Card className="animate-fadeIn opacity-0">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between gap-x-2 p-3">
@ -63,7 +63,7 @@ export default function DeviceList({
</div>
</Card>
<div
className="flex animate-fadeIn items-center justify-end space-x-2"
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -13,7 +13,7 @@ export default function EmptyStateCard({
}) {
return (
<div className="select-none space-y-4">
<Card className="animate-fadeIn">
<Card className="animate-fadeIn opacity-0">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
@ -35,7 +35,7 @@ export default function EmptyStateCard({
</div>
</Card>
<div
className="flex animate-fadeIn items-center justify-end space-x-2"
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -1,4 +1,4 @@
@import 'tailwindcss';
@import "tailwindcss";
@config "../tailwind.config.js";
@ -10,10 +10,10 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Circular', sans-serif;
--font-display: 'Circular', sans-serif;
--font-serif: 'Circular', serif;
--font-mono: 'Source Code Pro Variable', monospace;
--font-sans: "Circular", sans-serif;
--font-display: "Circular", sans-serif;
--font-serif: "Circular", serif;
--font-mono: "Source Code Pro Variable", monospace;
--grid-layout: auto 1fr auto;
--grid-headerBody: auto 1fr;
@ -122,13 +122,22 @@
}
}
/* If we don't ignore this, Prettier will add a space between the value and the `ms`. Rendering the utility invalid. */
/* prettier-ignore */
@utility max-width-* {
max-width: --modifier(--container- *, [length], [ *]);
max-width: --modifier(--container-*, [length], [*]);
}
/* Ensure there is not a `ms` and ms -> `...)ms` */
/* If we don't ignore this, Prettier will add a space between the value and the `ms`. Rendering the utility invalid. */
/* prettier-ignore */
@utility animation-delay-* {
animation-delay: --value(integer) ms;
animation-delay: --value(integer)ms;
}
/* If we don't ignore this, Prettier will add a space between the value and the `ms`. Rendering the utility invalid. */
/* prettier-ignore */
@utility animation-duration-* {
animation-duration: --value(integer)ms;
}
html {

View File

@ -7,7 +7,7 @@ import {
LuCheck,
LuUpload,
} from "react-icons/lu";
import { PlusCircleIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router-dom";
@ -38,7 +38,6 @@ import {
useRTCStore,
} from "../hooks/stores";
export default function MountRoute() {
const navigate = useNavigate();
{
@ -283,8 +282,8 @@ function ModeSelectionView({
return (
<div className="w-full space-y-4">
<div className="animate-fadeIn space-y-0">
<h2 className="text-lg font-bold leading-tight dark:text-white">
<div className="animate-fadeIn space-y-0 opacity-0">
<h2 className="text-lg leading-tight font-bold dark:text-white">
Virtual Media Source
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
@ -320,7 +319,7 @@ function ModeSelectionView({
].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => (
<div
key={label}
className={cx("animate-fadeIn")}
className="animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: `${25 * (index * 5)}ms`,
@ -337,7 +336,7 @@ function ModeSelectionView({
)}
>
<div
className="relative z-50 flex select-none flex-col items-start p-4"
className="relative z-50 flex flex-col items-start p-4 select-none"
onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
}
@ -365,7 +364,7 @@ function ModeSelectionView({
value={mode}
disabled={disabled}
checked={selectedMode === mode}
className="absolute right-4 top-4 h-4 w-4 text-blue-700"
className="absolute top-4 right-4 form-radio h-4 w-4 rounded-full text-blue-700"
/>
</div>
</Card>
@ -373,7 +372,7 @@ function ModeSelectionView({
))}
</div>
<div
className="flex animate-fadeIn justify-end"
className="flex animate-fadeIn justify-end opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -437,19 +436,19 @@ function BrowserFileView({
className="block cursor-pointer select-none"
>
<div
className="group animate-fadeIn"
className="group animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
}}
>
<Card className="outline-dashed transition-all duration-300 hover:bg-blue-50/50">
<Card className="transition-all duration-300 outline-dashed">
<div className="w-full px-4 py-12">
<div className="flex h-full flex-col items-center justify-center text-center">
{selectedFile ? (
<>
<div className="space-y-1">
<LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none">
<h3 className="text-sm leading-none font-semibold">
{formatters.truncateMiddle(selectedFile.name, 40)}
</h3>
<p className="text-xs leading-none text-slate-700">
@ -460,7 +459,7 @@ function BrowserFileView({
) : (
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none">
<h3 className="text-sm leading-none font-semibold">
Click to select a file
</h3>
<p className="text-xs leading-none text-slate-700">
@ -483,7 +482,7 @@ function BrowserFileView({
</div>
<div
className="flex w-full animate-fadeIn items-end justify-between"
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -578,7 +577,7 @@ function UrlView({
/>
<div
className="animate-fadeIn"
className="animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
}}
@ -593,7 +592,7 @@ function UrlView({
/>
</div>
<div
className="flex w-full animate-fadeIn items-end justify-between"
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -619,7 +618,7 @@ function UrlView({
<hr className="border-slate-800/30 dark:border-slate-300/20" />
<div
className="animate-fadeIn"
className="animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -628,13 +627,13 @@ function UrlView({
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images
</h2>
<Card className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
<Card className="w-full divide-y divide-slate-800/20 dark:divide-slate-300/20">
{popularImages.map((image, index) => (
<div key={index} className="flex items-center justify-between gap-x-4 p-3.5">
<div className="flex items-center gap-x-4">
<img src={image.icon} alt={`${image.name} Icon`} className="w-6" />
<div className="flex flex-col gap-y-1">
<h3 className="text-sm font-semibold leading-none dark:text-white">
<h3 className="text-sm leading-none font-semibold dark:text-white">
{formatters.truncateMiddle(image.name, 40)}
</h3>
{image.description && (
@ -797,7 +796,7 @@ function DeviceFileView({
description="Select an image to mount from the JetKVM storage"
/>
<div
className="w-full animate-fadeIn"
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -809,7 +808,7 @@ function DeviceFileView({
<div className="space-y-3">
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
No images available
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
@ -827,7 +826,7 @@ function DeviceFileView({
</div>
</div>
) : (
<div className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
<div className="w-full divide-y divide-slate-800/20 dark:divide-slate-300/20">
{currentFiles.map((file, index) => (
<PreUploadedImageItem
key={index}
@ -886,7 +885,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? (
<div
className="flex animate-fadeIn items-end justify-between"
className="flex animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.15s",
@ -914,7 +913,7 @@ function DeviceFileView({
</div>
) : (
<div
className="flex animate-fadeIn items-end justify-end"
className="flex animate-fadeIn items-end justify-end opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.15s",
@ -927,7 +926,7 @@ function DeviceFileView({
)}
<hr className="border-slate-800/20 dark:border-slate-300/20" />
<div
className="animate-fadeIn space-y-2"
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.20s",
@ -959,7 +958,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 && (
<div
className="w-full animate-fadeIn"
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.25s",
@ -1251,7 +1250,7 @@ function UploadFileView({
}
/>
<div
className="animate-fadeIn space-y-2"
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
}}
@ -1267,7 +1266,7 @@ function UploadFileView({
<div className="group">
<Card
className={cx("transition-all duration-300", {
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50":
"cursor-pointer hover:bg-blue-50/50 dark:hover:bg-blue-900/50":
uploadState === "idle",
})}
>
@ -1282,7 +1281,7 @@ function UploadFileView({
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
{incompleteFileName
? `Click to select "${incompleteFileName.replace(".incomplete", "")}"`
: "Click to select a file"}
@ -1336,7 +1335,7 @@ function UploadFileView({
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
Upload successful
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
@ -1365,7 +1364,7 @@ function UploadFileView({
{/* Display upload error if present */}
{uploadError && (
<div
className="mt-2 animate-fadeIn truncate text-sm text-red-600 dark:text-red-400"
className="mt-2 animate-fadeIn truncate text-sm text-red-600 dark:text-red-400 opacity-0"
style={{ animationDuration: "0.7s" }}
>
Error: {uploadError}
@ -1373,7 +1372,7 @@ function UploadFileView({
)}
<div
className="flex w-full animate-fadeIn items-end"
className="flex w-full animate-fadeIn items-end opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -1422,7 +1421,7 @@ function ErrorView({
<div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="h-6 w-6" />
<h2 className="text-lg font-bold leading-tight">Mount Error</h2>
<h2 className="text-lg leading-tight font-bold">Mount Error</h2>
</div>
<p className="text-sm leading-snug text-slate-600">
An error occurred while attempting to mount the media. Please try again.
@ -1481,8 +1480,8 @@ function PreUploadedImageItem({
}}
>
<div className="flex items-center gap-x-4">
<div className="select-none space-y-0.5">
<div className="text-sm font-semibold leading-none dark:text-white">
<div className="space-y-0.5 select-none">
<div className="text-sm leading-none font-semibold dark:text-white">
{formatters.truncateMiddle(name, 45)}
</div>
<div className="flex items-center text-sm">
@ -1494,7 +1493,7 @@ function PreUploadedImageItem({
</div>
</div>
</div>
<div className="relative flex select-none items-center gap-x-3">
<div className="relative flex items-center gap-x-3 select-none">
<div
className={cx("opacity-0 transition-opacity duration-200", {
"w-auto opacity-100": isHovering,
@ -1518,7 +1517,7 @@ function PreUploadedImageItem({
checked={isSelected}
onChange={onSelect}
name={name}
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 focus:ring-blue-500 disabled:opacity-30 dark:border-slate-300/20 dark:bg-slate-800"
className="form-radio h-3 w-3 border-slate-800/30 bg-white text-blue-700 focus:ring-blue-500 disabled:opacity-30 dark:border-slate-300/20 dark:bg-slate-800"
onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect
/>
) : (
@ -1540,7 +1539,7 @@ function PreUploadedImageItem({
function ViewHeader({ title, description }: { title: string; description: string }) {
return (
<div className="space-y-0">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
{title}
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
@ -1558,7 +1557,7 @@ function UsbModeSelector({
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) {
return (
<div className="flex select-none flex-col items-start space-y-1">
<div className="flex flex-col items-start space-y-1 select-none">
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label>
<div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center">
@ -1568,7 +1567,7 @@ function UsbModeSelector({
name="mountType"
onChange={() => setUsbMode("CDROM")}
checked={usbMode === "CDROM"}
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
className="form-radio h-3 w-3 rounded-full border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/>
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
CD/DVD
@ -1581,13 +1580,11 @@ function UsbModeSelector({
name="mountType"
checked={usbMode === "Disk"}
onChange={() => setUsbMode("Disk")}
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
className="form-radio h-3 w-3 rounded-full border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/>
<div className="ml-2 flex flex-col gap-y-0">
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
Disk
</span>
</div>
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
Disk
</span>
</label>
</div>
</div>

View File

@ -409,7 +409,7 @@ export default function SettingsAccessIndexRoute() {
.
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<hr className="block w-full border-slate-800/20 dark:border-slate-300/20" />
<div>
<LinkButton

View File

@ -43,9 +43,10 @@ export default function SettingsGeneralUpdateRoute() {
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
remote: { appVersion: string; systemVersion: string };
remote?: { appVersion: string; systemVersion: string };
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
error?: string;
}
export function Dialog({
@ -142,13 +143,19 @@ function LoadingState({
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error("Failed to check for updates");
notifications.error(`Failed to check for updates: ${resp.error}`);
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
resolve(result);
if (result.error) {
notifications.error(`Failed to check for updates: ${result.error}`);
reject(new Error("Failed to check for updates"));
} else {
resolve(result);
}
}
});
});
@ -235,9 +242,9 @@ function UpdatingDeviceState({
console.log(
`For ${type}:\n` +
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
);
if (type === "app") {
@ -442,13 +449,14 @@ function UpdateAvailableState({
{versionInfo?.systemUpdateAvailable ? (
<>
<span className="font-semibold">System:</span>{" "}
{versionInfo?.remote.systemVersion}
{versionInfo?.remote?.systemVersion}
<br />
</>
) : null}
{versionInfo?.appUpdateAvailable ? (
<>
<span className="font-semibold">App:</span> {versionInfo?.remote.appVersion}
<span className="font-semibold">App:</span>{" "}
{versionInfo?.remote?.appVersion}
</>
) : null}
</p>

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { LuEthernetPort } from "react-icons/lu";
import {
IPv4Mode,
@ -16,13 +16,18 @@ import {
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import InputField from "@components/InputField";
import InputField, { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import Fieldset from "@/components/Fieldset";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import notifications from "@/notifications";
import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
import EmptyCard from "../components/EmptyCard";
import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard";
import { SettingsItem } from "./devices.$id.settings";
dayjs.extend(relativeTime);
@ -56,15 +61,11 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
return (
<>
<strong>{dayjs(lifetime).format("YYYY-MM-DD HH:mm")}</strong>
{remaining && (
<>
{" "}
<span className="text-xs text-slate-700 dark:text-slate-300">
({remaining})
</span>
</>
)}
<span className="text-sm font-medium">{remaining && <> {remaining}</>}</span>
<span className="text-xs text-slate-700 dark:text-slate-300">
{" "}
({dayjs(lifetime).format("YYYY-MM-DD HH:mm")})
</span>
</>
);
}
@ -114,6 +115,14 @@ export default function SettingsNetworkRoute() {
});
}, [send]);
const getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkState(resp.result as NetworkState);
});
}, [send, setNetworkState]);
const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => {
setNetworkSettingsLoaded(false);
@ -129,21 +138,14 @@ export default function SettingsNetworkRoute() {
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
firstNetworkSettings.current = resp.result as NetworkSettings;
setNetworkSettings(resp.result as NetworkSettings);
getNetworkState();
setNetworkSettingsLoaded(true);
notifications.success("Network settings saved");
});
},
[send],
[getNetworkState, send],
);
const getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkState(resp.result as NetworkState);
});
}, [send, setNetworkState]);
const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, resp => {
if ("error" in resp) {
@ -171,10 +173,6 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode });
};
// const handleLldpTxTlvsChange = (value: string[]) => {
// setNetworkSettings({ ...networkSettings, lldp_tx_tlvs: value });
// };
const handleMdnsModeChange = (value: mDNSMode | string) => {
setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode });
};
@ -258,7 +256,7 @@ export default function SettingsNetworkRoute() {
</div>
<div className="space-y-4">
<div className="space-y-4">
<div className="space-y-1">
<SettingsItem
title="Domain"
description="Network domain suffix for the device"
@ -277,19 +275,17 @@ export default function SettingsNetworkRoute() {
</div>
</SettingsItem>
{selectedDomainOption === "custom" && (
<div className="flex items-center justify-between gap-x-2">
<InputField
<div className="mt-2 w-1/3 border-l border-slate-800/10 pl-4 dark:border-slate-300/20">
<InputFieldWithLabel
size="SM"
type="text"
label="Custom Domain"
placeholder="home"
value={customDomain}
onChange={e => setCustomDomain(e.target.value)}
/>
<Button
size="SM"
theme="primary"
text="Save Domain"
onClick={() => handleCustomDomainChange(customDomain)}
onChange={e => {
setCustomDomain(e.target.value);
handleCustomDomainChange(e.target.value);
}}
/>
</div>
)}
@ -359,209 +355,35 @@ export default function SettingsNetworkRoute() {
])}
/>
</SettingsItem>
{networkState?.dhcp_lease && (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease
</h3>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
</span>
</div>
)}
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
</span>
</div>
)}
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => (
<div key={dns}>{dns}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
</span>
</div>
)}
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
</span>
</div>
)}
{networkState?.dhcp_lease?.ntp_servers &&
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
<div key={server}>{server}</div>
))}
</div>
</div>
)}
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
</span>
</div>
)}
<AutoHeight>
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
</div>
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.routers &&
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
<div key={router}>{router}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
</span>
</div>
)}
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
/>
</span>
</div>
)}
{networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
MTU
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.mtu}
</span>
</div>
)}
{networkState?.dhcp_lease?.ttl && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
TTL
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ttl}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
</span>
</div>
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={ArrowPathIcon}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
</div>
</GridCard>
)}
</GridCard>
) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? (
<DhcpLeaseCard
networkState={networkState}
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
/>
) : (
<EmptyCard
IconElm={LuEthernetPort}
headline="DHCP Information"
description="No DHCP lease information available"
/>
)}
</AutoHeight>
</div>
<div className="space-y-4">
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
@ -579,93 +401,33 @@ export default function SettingsNetworkRoute() {
])}
/>
</SettingsItem>
{networkState?.ipv6_addresses && (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}
</span>
</div>
)}
</div>
<div className="space-y-3 pt-2">
{networkState?.ipv6_addresses &&
networkState?.ipv6_addresses.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
{networkState.ipv6_addresses.map(addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-slate-100/40 p-4 pl-4 dark:border-blue-500 dark:bg-slate-900"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
</span>
<span className="text-sm font-medium">
{addr.address}
</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel
lifetime={`${addr.valid_lifetime}`}
/>
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel
lifetime={`${addr.preferred_lifetime}`}
/>
)}
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
<AutoHeight>
{!networkSettingsLoaded &&
!(networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0) ? (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
</div>
</div>
</div>
</div>
</GridCard>
)}
</GridCard>
) : networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0 ? (
<Ipv6NetworkCard networkState={networkState} />
) : (
<EmptyCard
IconElm={LuEthernetPort}
headline="IPv6 Information"
description="No IPv6 addresses configured"
/>
)}
</AutoHeight>
</div>
<div className="hidden space-y-4">
<SettingsItem

View File

@ -478,6 +478,8 @@ export default function KvmIdRoute() {
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
// Add audio transceiver to receive audio from the server
pc.addTransceiver("audio", { direction: "recvonly" });
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => {
@ -703,12 +705,17 @@ export default function KvmIdRoute() {
send("getUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error("Failed to get device version");
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
notifications.error(`Failed to get device version: ${resp.error}`);
return
}
const result = resp.result as SystemVersionInfo;
if (result.error) {
notifications.error(`Failed to get device version: ${result.error}`);
}
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
});
}, [appVersion, send, setAppVersion, setSystemVersion]);

View File

@ -116,7 +116,7 @@ export default function WelcomeLocalModeRoute() {
onChange={() => {
setSelectedMode(mode as "password" | "noPassword");
}}
className="absolute top-2 right-2 h-4 w-4 text-blue-600"
className="form-radio absolute top-2 right-2 h-4 w-4 text-blue-600"
/>
</div>
</GridCard>

View File

@ -5,7 +5,8 @@ import (
)
// max frame size for 1080p video, specified in mpp venc setting
const maxFrameSize = 1920 * 1080 / 2
const maxVideoFrameSize = 1920 * 1080 / 2
const maxAudioFrameSize = 1500
func writeCtrlAction(action string) error {
actionMessage := map[string]string{

View File

@ -18,6 +18,7 @@ import (
type Session struct {
peerConnection *webrtc.PeerConnection
VideoTrack *webrtc.TrackLocalStaticSample
AudioTrack *webrtc.TrackLocalStaticRTP
ControlChannel *webrtc.DataChannel
RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
@ -136,7 +137,17 @@ func newSession(config SessionConfig) (*Session, error) {
return nil, err
}
rtpSender, err := peerConnection.AddTrack(session.VideoTrack)
session.AudioTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "kvm")
if err != nil {
return nil, err
}
videoRtpSender, err := peerConnection.AddTrack(session.VideoTrack)
if err != nil {
return nil, err
}
audioRtpSender, err := peerConnection.AddTrack(session.AudioTrack)
if err != nil {
return nil, err
}
@ -144,14 +155,9 @@ func newSession(config SessionConfig) (*Session, error) {
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
go drainRtpSender(videoRtpSender)
go drainRtpSender(audioRtpSender)
var isConnected bool
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
@ -203,6 +209,15 @@ func newSession(config SessionConfig) (*Session, error) {
return session, nil
}
func drainRtpSender(rtpSender *webrtc.RTPSender) {
rtcpBuf := make([]byte, 1500)
for {
if _, _, err := rtpSender.Read(rtcpBuf); err != nil {
return
}
}
}
var actionSessions = 0
func onActiveSessionsChanged() {