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 - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: v21.1.0 node-version: "22"
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.3"
- name: Build frontend - name: Build frontend
run: | run: |
make frontend make frontend

View File

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

View File

@ -106,7 +106,7 @@ jobs:
- 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.3"
- name: Golang Test Report - name: Golang Test Report
uses: becheran/go-testreport@v0.3.2 uses: becheran/go-testreport@v0.3.2
with: with:

View File

@ -25,7 +25,10 @@ hash_resource:
build_dev: hash_resource build_dev: hash_resource
@echo "Building..." @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: build_test2json:
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json $(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
@ -66,7 +69,10 @@ dev_release: frontend build_dev
build_release: frontend hash_resource build_release: frontend hash_resource
@echo "Building release..." @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: release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ @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, RelativeMouse: true,
Keyboard: true, Keyboard: true,
MassStorage: true, MassStorage: true,
Audio: true,
}, },
NetworkConfig: &network.NetworkConfig{}, NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO", DefaultLogLevel: "INFO",

View File

@ -59,6 +59,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
// mass storage // mass storage
"mass_storage_base": massStorageBaseConfig, "mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config, "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 { func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
@ -73,6 +90,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage return u.enabledDevices.MassStorage
case "mass_storage_lun0": case "mass_storage_lun0":
return u.enabledDevices.MassStorage return u.enabledDevices.MassStorage
case "audio":
return u.enabledDevices.Audio
default: default:
return true return true
} }

View File

@ -18,6 +18,7 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"` RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"` Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"` MassStorage bool `json:"mass_storage"`
Audio bool `json:"audio"`
} }
// Config is a struct that represents the customizations for a USB gadget. // 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) { func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), 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 { 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 return updateStatus, nil

View File

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

View File

@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/jetkvm/kvm/resource" "github.com/jetkvm/kvm/resource"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4/pkg/media" "github.com/pion/webrtc/v4/pkg/media"
) )
@ -215,7 +216,7 @@ func handleVideoClient(conn net.Conn) {
scopedLogger.Info().Msg("native video socket client connected") scopedLogger.Info().Msg("native video socket client connected")
inboundPacket := make([]byte, maxFrameSize) inboundPacket := make([]byte, maxVideoFrameSize)
lastFrame := time.Now() lastFrame := time.Now()
for { for {
n, err := conn.Read(inboundPacket) 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 { func ExtractAndRunNativeBin() error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native" binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil { if err := ensureBinaryUpdated(binaryPath); err != nil {

40
ota.go
View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ const sizes = {
const checkboxVariants = cva({ const checkboxVariants = cva({
base: cx( base: cx(
"block rounded", "form-checkbox block rounded",
// Colors // 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", "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 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-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 */} {/* 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

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

View File

@ -107,7 +107,7 @@ export function ATXPowerControl() {
<LoadingSpinner className="h-6 w-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"> <Card className="h-[120px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <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">

View File

@ -63,7 +63,7 @@ export function DCPowerControl() {
<LoadingSpinner className="h-6 w-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"> <Card className="h-[160px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <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">

View File

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

View File

@ -92,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()} {renderActiveExtension()}
<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={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -113,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="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

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

View File

@ -83,7 +83,7 @@ export default function PasteModal() {
/> />
<div <div
className="animate-fadeIn space-y-2" className="animate-fadeIn opacity-0 space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -137,7 +137,7 @@ export default function PasteModal() {
</div> </div>
</div> </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={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

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

View File

@ -28,7 +28,7 @@ export default function DeviceList({
}: DeviceListProps) { }: DeviceListProps) {
return ( return (
<div className="space-y-4"> <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"> <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 gap-x-2 p-3"> <div key={index} className="flex items-center justify-between gap-x-2 p-3">
@ -63,7 +63,7 @@ export default function DeviceList({
</div> </div>
</Card> </Card>
<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={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -13,7 +13,7 @@ export default function EmptyStateCard({
}) { }) {
return ( return (
<div className="select-none space-y-4"> <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="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">
@ -35,7 +35,7 @@ export default function EmptyStateCard({
</div> </div>
</Card> </Card>
<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={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -1,4 +1,4 @@
@import 'tailwindcss'; @import "tailwindcss";
@config "../tailwind.config.js"; @config "../tailwind.config.js";
@ -10,10 +10,10 @@
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
--font-sans: 'Circular', sans-serif; --font-sans: "Circular", sans-serif;
--font-display: 'Circular', sans-serif; --font-display: "Circular", sans-serif;
--font-serif: 'Circular', serif; --font-serif: "Circular", serif;
--font-mono: 'Source Code Pro Variable', monospace; --font-mono: "Source Code Pro Variable", monospace;
--grid-layout: auto 1fr auto; --grid-layout: auto 1fr auto;
--grid-headerBody: auto 1fr; --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-* { @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-* { @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 { html {

View File

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

View File

@ -409,7 +409,7 @@ export default function SettingsAccessIndexRoute() {
. .
</div> </div>
</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> <div>
<LinkButton <LinkButton
@ -469,4 +469,4 @@ export default function SettingsAccessIndexRoute() {
); );
} }
SettingsAccessIndexRoute.loader = loader; SettingsAccessIndexRoute.loader = loader;

View File

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

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { LuEthernetPort } from "react-icons/lu";
import { import {
IPv4Mode, IPv4Mode,
@ -16,13 +16,18 @@ import {
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import InputField from "@components/InputField"; import InputField, { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import notifications from "@/notifications"; 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"; import { SettingsItem } from "./devices.$id.settings";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -56,15 +61,11 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
return ( return (
<> <>
<strong>{dayjs(lifetime).format("YYYY-MM-DD HH:mm")}</strong> <span className="text-sm font-medium">{remaining && <> {remaining}</>}</span>
{remaining && ( <span className="text-xs text-slate-700 dark:text-slate-300">
<> {" "}
{" "} ({dayjs(lifetime).format("YYYY-MM-DD HH:mm")})
<span className="text-xs text-slate-700 dark:text-slate-300"> </span>
({remaining})
</span>
</>
)}
</> </>
); );
} }
@ -114,6 +115,14 @@ export default function SettingsNetworkRoute() {
}); });
}, [send]); }, [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( const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => { (settings: NetworkSettings) => {
setNetworkSettingsLoaded(false); 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 // 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; firstNetworkSettings.current = resp.result as NetworkSettings;
setNetworkSettings(resp.result as NetworkSettings); setNetworkSettings(resp.result as NetworkSettings);
getNetworkState();
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
notifications.success("Network settings saved"); 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(() => { const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, resp => { send("renewDHCPLease", {}, resp => {
if ("error" in resp) { if ("error" in resp) {
@ -171,10 +173,6 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode }); setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode });
}; };
// const handleLldpTxTlvsChange = (value: string[]) => {
// setNetworkSettings({ ...networkSettings, lldp_tx_tlvs: value });
// };
const handleMdnsModeChange = (value: mDNSMode | string) => { const handleMdnsModeChange = (value: mDNSMode | string) => {
setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode }); setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode });
}; };
@ -258,7 +256,7 @@ export default function SettingsNetworkRoute() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4"> <div className="space-y-1">
<SettingsItem <SettingsItem
title="Domain" title="Domain"
description="Network domain suffix for the device" description="Network domain suffix for the device"
@ -277,19 +275,17 @@ export default function SettingsNetworkRoute() {
</div> </div>
</SettingsItem> </SettingsItem>
{selectedDomainOption === "custom" && ( {selectedDomainOption === "custom" && (
<div className="flex items-center justify-between gap-x-2"> <div className="mt-2 w-1/3 border-l border-slate-800/10 pl-4 dark:border-slate-300/20">
<InputField <InputFieldWithLabel
size="SM" size="SM"
type="text" type="text"
label="Custom Domain"
placeholder="home" placeholder="home"
value={customDomain} value={customDomain}
onChange={e => setCustomDomain(e.target.value)} onChange={e => {
/> setCustomDomain(e.target.value);
<Button handleCustomDomainChange(e.target.value);
size="SM" }}
theme="primary"
text="Save Domain"
onClick={() => handleCustomDomainChange(customDomain)}
/> />
</div> </div>
)} )}
@ -359,209 +355,35 @@ export default function SettingsNetworkRoute() {
])} ])}
/> />
</SettingsItem> </SettingsItem>
{networkState?.dhcp_lease && ( <AutoHeight>
<GridCard> {!networkSettingsLoaded && !networkState?.dhcp_lease ? (
<div className="p-4"> <GridCard>
<div className="space-y-4"> <div className="p-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <div className="space-y-4">
DHCP Lease <h3 className="text-base font-bold text-slate-900 dark:text-white">
</h3> DHCP Lease Information
</h3>
<div className="flex gap-x-6 gap-y-2"> <div className="animate-pulse space-y-3">
<div className="flex-1 space-y-2"> <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
{networkState?.dhcp_lease?.ip && ( <div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20"> <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<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>
<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> </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>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode"> <SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
@ -579,93 +401,33 @@ export default function SettingsNetworkRoute() {
])} ])}
/> />
</SettingsItem> </SettingsItem>
{networkState?.ipv6_addresses && ( <AutoHeight>
<GridCard> {!networkSettingsLoaded &&
<div className="p-4"> !(networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0) ? (
<div className="space-y-4"> <GridCard>
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <div className="p-4">
IPv6 Information <div className="space-y-4">
</h3> <h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
<div className="grid grid-cols-2 gap-x-6 gap-y-2"> </h3>
{networkState?.dhcp_lease?.ip && ( <div className="animate-pulse space-y-3">
<div className="flex flex-col justify-between"> <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<span className="text-sm text-slate-600 dark:text-slate-400"> <div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
Link-local <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
</span> </div>
<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>
)}
</div> </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>
<div className="hidden space-y-4"> <div className="hidden space-y-4">
<SettingsItem <SettingsItem

View File

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

View File

@ -116,7 +116,7 @@ export default function WelcomeLocalModeRoute() {
onChange={() => { onChange={() => {
setSelectedMode(mode as "password" | "noPassword"); 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> </div>
</GridCard> </GridCard>

View File

@ -5,7 +5,8 @@ import (
) )
// max frame size for 1080p video, specified in mpp venc setting // 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 { func writeCtrlAction(action string) error {
actionMessage := map[string]string{ actionMessage := map[string]string{

View File

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