Optimize some components

refactor: optimize mouse jiggler
refactor: custom swap file size
refactor: update power buttons ui
refactor: optimize web page title
perf: set the minimum screen size to 640x480
perf: add heartbeat in H.264
chore: upgrade axios and golang.org/x/net
This commit is contained in:
wj-xiao
2025-04-17 15:20:25 +08:00
parent 59a03cacef
commit c83dc5565c
51 changed files with 1164 additions and 857 deletions

View File

@@ -17,7 +17,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/unrolled/secure v1.15.0
golang.org/x/crypto v0.36.0
golang.org/x/crypto v0.37.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -73,9 +73,9 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

View File

@@ -164,12 +164,16 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -177,10 +181,14 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,12 +1,6 @@
package main
import (
"NanoKVM-Server/common"
"NanoKVM-Server/config"
"NanoKVM-Server/logger"
"NanoKVM-Server/middleware"
"NanoKVM-Server/router"
"NanoKVM-Server/utils"
"fmt"
"log"
"os"
@@ -14,6 +8,13 @@ import (
"syscall"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/config"
"NanoKVM-Server/logger"
"NanoKVM-Server/middleware"
"NanoKVM-Server/router"
"NanoKVM-Server/service/vm/jiggler"
"github.com/gin-gonic/gin"
cors "github.com/rs/cors/wrapper/gin"
)
@@ -37,7 +38,8 @@ func initialize() {
time.Sleep(10 * time.Millisecond)
vision.SetHDMI(true)
utils.InitGoMemLimit()
// run mouse jiggler
jiggler.GetJiggler().Run()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)

View File

@@ -69,8 +69,8 @@ type UpdateVirtualDeviceRsp struct {
}
type SetMemoryLimitReq struct {
Enabled bool `json:"enabled"`
Limit int64 `json:"limit"`
Enabled bool `validate:"omitempty"`
Limit int64 `validate:"omitempty"`
}
type GetMemoryLimitRsp struct {
@@ -79,7 +79,7 @@ type GetMemoryLimitRsp struct {
}
type SetOledReq struct {
Sleep int `json:"sleep"`
Sleep int `validate:"omitempty"`
}
type GetOLEDRsp struct {
@@ -91,12 +91,22 @@ type GetSSHStateRsp struct {
Enabled bool `json:"enabled"`
}
type GetSwapStateRsp struct {
Enabled bool `json:"enabled"`
type GetSwapRsp struct {
Size int64 `json:"size"` // unit: MB
}
type GetMouseJigglerStateRsp struct {
Enabled bool `json:"enabled"`
type SetSwapReq struct {
Size int64 `validate:"omitempty"` // unit: MB
}
type GetMouseJigglerRsp struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
}
type SetMouseJigglerReq struct {
Enabled bool `validate:"omitempty"`
Mode string `validate:"omitempty"`
}
type GetMdnsStateRsp struct {
@@ -104,7 +114,7 @@ type GetMdnsStateRsp struct {
}
type SetHostnameReq struct {
Hostname string `json:"hostname"`
Hostname string `validate:"required"`
}
type GetHostnameRsp struct {
@@ -112,9 +122,9 @@ type GetHostnameRsp struct {
}
type SetWebTitleReq struct {
WebTitle string `json:"webTitle"`
Title string `validate:"omitempty"`
}
type GetWebTitleRsp struct {
WebTitle string `json:"webTitle"`
Title string `json:"title"`
}

View File

@@ -14,9 +14,9 @@ func networkRouter(r *gin.Engine) {
api := r.Group("/api").Use(middleware.CheckToken())
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
api.GET("/network/wol/mac", service.GetMac) // get mac list
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
api.POST("/network/wol/mac/setName", service.SetMacName) // set mac name
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
api.GET("/network/wol/mac", service.GetMac) // get mac list
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
api.POST("/network/wol/mac/name", service.SetMacName) // set mac name
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
}

View File

@@ -9,6 +9,7 @@ import (
func vmRouter(r *gin.Engine) {
service := vm.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.GET("/vm/info", service.GetInfo) // get device information
@@ -40,19 +41,17 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
api.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
api.GET("/vm/swap", service.GetSwapState) // get Swap state
api.POST("/vm/swap/enable", service.EnableSwap) // enable Swap
api.POST("/vm/swap/disable", service.DisableSwap) // disable Swap
api.GET("/vm/swap", service.GetSwap) // get swap file size
api.POST("/vm/swap", service.SetSwap) // set swap file size
api.GET("/vm/mouseJiggler", service.GetMouseJigglerState) // get MouseJiggler state
api.POST("/vm/mouseJiggler/enable", service.EnableMouseJiggler) // enable MouseJiggler
api.POST("/vm/mouseJiggler/disable", service.DisableMouseJiggler) // disable MouseJiggler
api.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
api.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
api.POST("/vm/hostname", service.SetHostname) // Set Hostname
api.GET("/vm/webTitle", service.GetWebTitle) // Get WebTitle
api.POST("/vm/webTitle", service.SetWebTitle) // Set WebTitle
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
api.POST("/vm/web-title", service.SetWebTitle) // Set web title
api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS

View File

@@ -4,17 +4,11 @@ import (
"encoding/binary"
"errors"
"os"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
var (
LastHIDTime time.Time
HidMutex sync.Mutex
)
const (
MouseUp = iota
MouseDown
@@ -49,7 +43,6 @@ func (h *Hid) Mouse(queue <-chan []int) {
}
h.mouseMutex.Unlock()
UpdateLastHIDTime()
}
}
@@ -116,9 +109,3 @@ func (h *Hid) writeWithTimeout(file *os.File, data []byte) {
log.Debugf("write to hid: %+v", data)
}
func UpdateLastHIDTime() {
HidMutex.Lock()
defer HidMutex.Unlock()
LastHIDTime = time.Now()
}

View File

@@ -138,6 +138,9 @@ func (c *Client) readMessage() {
return
}
case "heartbeat":
_ = c.sendMessage("heartbeat", "")
default:
log.Debugf("unhandled message event: %s", message.Event)
}

View File

@@ -23,12 +23,13 @@ func (s *Service) SetGpio(c *gin.Context) {
}
device := ""
conf := config.GetInstance().Hardware
switch req.Type {
case "power":
device = s.config.Hardware.GPIOPower
device = conf.GPIOPower
case "reset":
device = s.config.Hardware.GPIOReset
device = conf.GPIOReset
default:
rsp.ErrRsp(c, -2, fmt.Sprintf("invalid power event: %s", req.Type))
return
@@ -53,15 +54,17 @@ func (s *Service) SetGpio(c *gin.Context) {
func (s *Service) GetGpio(c *gin.Context) {
var rsp proto.Response
pwr, err := readGpio(s.config.Hardware.GPIOPowerLED)
conf := config.GetInstance().Hardware
pwr, err := readGpio(conf.GPIOPowerLED)
if err != nil {
rsp.ErrRsp(c, -2, fmt.Sprintf("failed to read power led: %s", err))
return
}
hdd := false
if s.config.Hardware.Version == config.HWVersionAlpha {
hdd, err = readGpio(s.config.Hardware.GPIOHDDLed)
if conf.Version == config.HWVersionAlpha {
hdd, err = readGpio(conf.GPIOHDDLed)
if err != nil {
rsp.ErrRsp(c, -2, fmt.Sprintf("failed to read hdd led: %s", err))
return

View File

@@ -1,17 +1,19 @@
package vm
import (
"NanoKVM-Server/proto"
"fmt"
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
HostnameFile = "/etc/hostname"
BootHostnameFile = "/boot/hostname"
EtcHostname = "/etc/hostname"
)
func (s *Service) SetHostname(c *gin.Context) {
@@ -25,12 +27,16 @@ func (s *Service) SetHostname(c *gin.Context) {
data := []byte(fmt.Sprintf("%s", req.Hostname))
err := os.WriteFile(HostnameFile, data, 0o644)
if err != nil {
if err := os.WriteFile(BootHostnameFile, data, 0o644); err != nil {
rsp.ErrRsp(c, -2, "failed to write data")
return
}
if err := os.WriteFile(EtcHostname, data, 0o644); err != nil {
rsp.ErrRsp(c, -3, "failed to write data")
return
}
rsp.OkRsp(c)
log.Debugf("set Hostname: %s", req.Hostname)
}
@@ -38,7 +44,7 @@ func (s *Service) SetHostname(c *gin.Context) {
func (s *Service) GetHostname(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(HostnameFile)
data, err := os.ReadFile(EtcHostname)
if err != nil {
rsp.ErrRsp(c, -1, "read Hostname failed")
return

View File

@@ -18,6 +18,7 @@ var imageVersionMap = map[string]string{
"2024-08-08-19-44-bef2ca.img": "v1.2.0",
"2024-11-13-09-59-9c961a.img": "v1.3.0",
"2025-02-17-19-08-3649fe.img": "v1.4.0",
"2025-04-17-14-21-98d17d.img": "v1.4.1",
}
func (s *Service) GetInfo(c *gin.Context) {

View File

@@ -0,0 +1,119 @@
package jiggler
import (
"os"
"strings"
"sync"
"time"
)
const (
ConfigFile = "/etc/kvm/mouse-jiggler"
Interval = 15 * time.Second
)
var (
jiggler Jiggler
once sync.Once
)
type Jiggler struct {
mutex sync.Mutex
enabled bool
running bool
mode string
lastUpdated time.Time
}
func GetJiggler() *Jiggler {
once.Do(func() {
jiggler = Jiggler{
mutex: sync.Mutex{},
enabled: false,
running: false,
mode: "relative",
lastUpdated: time.Now(),
}
content, err := os.ReadFile(ConfigFile)
if err != nil {
return
}
mode := strings.ReplaceAll(string(content), "\n", "")
if mode != "" {
jiggler.mode = mode
}
jiggler.enabled = true
})
return &jiggler
}
func (j *Jiggler) Enable(mode string) error {
err := os.WriteFile(ConfigFile, []byte(mode), 0644)
if err != nil {
return err
}
j.enabled = true
j.mode = mode
j.Run()
return nil
}
func (j *Jiggler) Disable() error {
if err := os.Remove(ConfigFile); err != nil {
return err
}
j.enabled = false
j.mode = "relative"
return nil
}
func (j *Jiggler) Run() {
if !j.enabled || j.running {
return
}
j.mutex.Lock()
j.running = true
j.mutex.Unlock()
j.Update()
go func() {
ticker := time.NewTicker(Interval)
defer ticker.Stop()
for range ticker.C {
if !j.enabled {
j.running = false
return
}
if time.Since(j.lastUpdated) > Interval {
move(j.mode)
j.Update()
}
}
}()
}
func (j *Jiggler) Update() {
if j.running {
j.lastUpdated = time.Now()
}
}
func (j *Jiggler) IsEnabled() bool {
return j.enabled
}
func (j *Jiggler) GetMode() string {
return j.mode
}

View File

@@ -0,0 +1,55 @@
package jiggler
import (
"os"
"time"
log "github.com/sirupsen/logrus"
)
func move(mode string) {
var (
hid string
data [][]byte
)
if mode == "absolute" {
hid = "/dev/hidg2"
data = [][]byte{
{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00},
{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00},
}
} else {
hid = "/dev/hidg1"
data = [][]byte{
{0x00, 0x0a, 0x0a, 0x00},
{0x00, 0xf6, 0xf6, 0x00},
}
}
write(hid, data)
}
func write(hid string, data [][]byte) {
file, err := os.OpenFile(hid, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("failed to open %s: %s", hid, err)
return
}
defer func() {
_ = file.Close()
}()
for _, b := range data {
deadline := time.Now().Add(8 * time.Millisecond)
if err := file.SetWriteDeadline(deadline); err != nil {
log.Errorf("failed to set deadline: %s", err)
return
}
if _, err := file.Write(b); err != nil {
log.Errorf("failed to write: %s", err)
return
}
}
}

View File

@@ -0,0 +1,49 @@
package vm
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/service/vm/jiggler"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func (s *Service) GetMouseJiggler(c *gin.Context) {
var rsp proto.Response
mouseJiggler := jiggler.GetJiggler()
data := &proto.GetMouseJigglerRsp{
Enabled: mouseJiggler.IsEnabled(),
Mode: mouseJiggler.GetMode(),
}
rsp.OkRspWithData(c, data)
}
func (s *Service) SetMouseJiggler(c *gin.Context) {
var req proto.SetMouseJigglerReq
var rsp proto.Response
err := proto.ParseFormRequest(c, &req)
if err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
mouseJiggler := jiggler.GetJiggler()
if req.Enabled {
err = mouseJiggler.Enable(req.Mode)
} else {
err = mouseJiggler.Disable()
}
if err != nil {
rsp.ErrRsp(c, -2, "operation failed")
return
}
rsp.OkRsp(c)
log.Debugf("set mouse jiggler: %t", req.Enabled)
}

View File

@@ -1,146 +0,0 @@
package vm
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/service/hid"
"os"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
mouseJiggleInterval = 15
MouseJigglerStartFlag = "/etc/kvm/mouse-jiggler_start"
)
var (
jigglerEnabled int32
jigglerStopChan chan struct{}
)
type mouseReport struct {
buttons byte
xMove int8
yMove int8
wheel int8
}
func init() {
enabled := isMouseJigglerEnabled()
if enabled {
hid.UpdateLastHIDTime()
go mouseJigglerRoutine()
}
}
func (s *Service) GetMouseJigglerState(c *gin.Context) {
var rsp proto.Response
enabled := isMouseJigglerEnabled()
rsp.OkRspWithData(c, &proto.GetMouseJigglerStateRsp{
Enabled: enabled,
})
}
func (s *Service) EnableMouseJiggler(c *gin.Context) {
var rsp proto.Response
enabled := isMouseJigglerEnabled()
if enabled {
rsp.OkRsp(c)
return
}
atomic.StoreInt32(&jigglerEnabled, 1)
jigglerStopChan = make(chan struct{})
go mouseJigglerRoutine()
fp, err := os.Create(MouseJigglerStartFlag)
if err != nil {
log.Errorf("MouseJiggler enable failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
defer fp.Close()
rsp.OkRsp(c)
log.Debugf("MouseJiggler enabled")
}
func (s *Service) DisableMouseJiggler(c *gin.Context) {
var rsp proto.Response
enabled := isMouseJigglerEnabled()
if !enabled {
rsp.OkRsp(c)
return
}
atomic.StoreInt32(&jigglerEnabled, 0)
close(jigglerStopChan)
_ = os.Remove(MouseJigglerStartFlag)
rsp.OkRsp(c)
log.Debugf("MouseJiggler disabled")
}
func isMouseJigglerEnabled() bool {
if atomic.LoadInt32(&jigglerEnabled) == 1 {
return true
}
return false
}
func mouseJigglerRoutine() {
ticker := time.NewTicker(mouseJiggleInterval * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if shouldJiggleMouse() {
hid.UpdateLastHIDTime()
jiggleMouse()
}
case <-jigglerStopChan:
return
}
}
}
func shouldJiggleMouse() bool {
enabled := isMouseJigglerEnabled()
if !enabled {
return false
}
hid.HidMutex.Lock()
defer hid.HidMutex.Unlock()
if time.Since(hid.LastHIDTime) < mouseJiggleInterval*time.Second {
return false
}
return true
}
func jiggleMouse() {
moveRight := []byte{0x00, 0x0a, 0x00, 0x00}
moveLeft := []byte{0x00, 0xf6, 0x00, 0x00}
err := os.WriteFile("/dev/hidg1", moveRight, 0644)
if err != nil {
log.Debugf("MouseJiggler write error:", err)
return
}
err = os.WriteFile("/dev/hidg1", moveLeft, 0644)
if err != nil {
log.Debugf("MouseJiggler write error:", err)
return
}
}

View File

@@ -1,15 +1,8 @@
package vm
import (
"NanoKVM-Server/config"
)
type Service struct {
config *config.Config
}
func NewService() *Service {
return &Service{
config: config.GetInstance(),
}
return &Service{}
}

View File

@@ -1,12 +1,13 @@
package vm
import (
"NanoKVM-Server/proto"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -14,84 +15,147 @@ import (
const (
SwapFile = "/swapfile"
SwapSize = "128M"
InittabPath = "/etc/inittab"
TempInittab = "/etc/inittab.tmp"
TempInittab = "/etc/.inittab.tmp"
)
func (s *Service) GetSwapState(c *gin.Context) {
func (s *Service) GetSwap(c *gin.Context) {
var rsp proto.Response
enabled := isSwapEnabled()
rsp.OkRspWithData(c, &proto.GetSwapStateRsp{
Enabled: enabled,
rsp.OkRspWithData(c, &proto.GetSwapRsp{
Size: getSwapSize(),
})
}
func (s *Service) EnableSwap(c *gin.Context) {
func (s *Service) SetSwap(c *gin.Context) {
var rsp proto.Response
var req proto.SetSwapReq
if err := exec.Command("fallocate", "-l", SwapSize, SwapFile).Run(); err != nil {
log.Errorf("create swap partition failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if err := exec.Command("chmod", "600", SwapFile).Run(); err != nil {
log.Errorf("chmod failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
size := getSwapSize()
if req.Size == size {
rsp.OkRsp(c)
return
}
if err := exec.Command("mkswap", SwapFile).Run(); err != nil {
log.Errorf("formatting failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
if err := exec.Command("swapon", SwapFile).Run(); err != nil {
log.Errorf("enable swap partition failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
fstabEntry := fmt.Sprintf("\nsi11::sysinit:/sbin/swapon %s", SwapFile)
f, err := os.OpenFile(InittabPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Errorf("read fstab failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
defer f.Close()
_, err = f.WriteString(fstabEntry)
if err != nil {
log.Errorf("write fstab failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
if req.Size == 0 {
if err := disableSwap(); err != nil {
rsp.ErrRsp(c, -2, "disable swap failed")
return
}
if err := disableInittab(); err != nil {
rsp.ErrRsp(c, -3, "disable inittab failed")
return
}
} else {
if err := enableSwap(req.Size); err != nil {
rsp.ErrRsp(c, -4, "enable swap failed")
return
}
if err := enableInittab(); err != nil {
rsp.ErrRsp(c, -5, "enable inittab failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("Swap enabled")
}
func (s *Service) DisableSwap(c *gin.Context) {
var rsp proto.Response
func getSwapSize() int64 {
fileInfo, err := os.Stat(SwapFile)
if err != nil {
return 0
}
return fileInfo.Size() / 1024 / 1024
}
func enableSwap(size int64) error {
if getSwapSize() > 0 {
if err := disableSwap(); err != nil {
return err
}
}
commands := []string{
fmt.Sprintf("fallocate -l %dM %s", size, SwapFile),
fmt.Sprintf("chmod 600 %s", SwapFile),
fmt.Sprintf("mkswap %s", SwapFile),
fmt.Sprintf("swapon %s", SwapFile),
}
for _, command := range commands {
err := exec.Command("sh", "-c", command).Run()
if err != nil {
log.Errorf("failed to execute %s: %s", command, err)
return err
}
time.Sleep(300 * time.Millisecond)
}
log.Debugf("set swap file size: %d", size)
return nil
}
func disableSwap() error {
command := "swapoff -a"
if err := exec.Command("sh", "-c", command).Run(); err != nil {
log.Errorf("failed to execute swapoff: %s", err)
return err
}
if err := os.Remove(SwapFile); err != nil {
log.Errorf("failed to delete %s: %s", SwapFile, err)
return err
}
return nil
}
func enableInittab() error {
f, err := os.OpenFile(InittabPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Errorf("read inittab failed: %s", err)
return err
}
defer func() {
_ = f.Close()
}()
content := fmt.Sprintf("\nsi11::sysinit:/sbin/swapon %s", SwapFile)
_, err = f.WriteString(content)
if err != nil {
log.Errorf("write inittab failed: %s", err)
return err
}
log.Debugf("write to %s: %s", InittabPath, content)
return nil
}
func disableInittab() error {
defer func() {
_ = os.Remove(TempInittab)
}()
input, err := os.ReadFile(InittabPath)
if err != nil {
log.Errorf("read fstab failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
return err
}
lines := strings.Split(string(input), "\n")
output := make([]string, 0)
for _, line := range lines {
if !strings.Contains(line, SwapFile) {
if strings.HasSuffix(line, SwapFile) {
log.Debugf("%s delete line: %s", InittabPath, line)
} else {
output = append(output, line)
}
}
@@ -100,37 +164,13 @@ func (s *Service) DisableSwap(c *gin.Context) {
content = strings.TrimSuffix(content, "\n")
if err := os.WriteFile(TempInittab, []byte(content), 0644); err != nil {
log.Errorf("write temp fstab failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
return err
}
if err := os.Rename(TempInittab, InittabPath); err != nil {
log.Errorf("replace fstab failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
return err
}
if err := exec.Command("swapoff", "-a").Run(); err != nil {
log.Errorf("close swap partition failed: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
if err := os.Remove(SwapFile); err != nil && !os.IsNotExist(err) {
log.Warnf("delete swap file failed: %s (ignored)", err)
}
rsp.OkRsp(c)
log.Debugf("Swap disabled")
}
func isSwapEnabled() bool {
_, err := os.Stat(SwapFile)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false
}
}
return true
return nil
}

View File

@@ -0,0 +1,58 @@
package vm
import (
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
WebTitleFile = "/etc/kvm/web-title"
)
func (s *Service) SetWebTitle(c *gin.Context) {
var req proto.SetWebTitleReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if req.Title == "" || req.Title == "NanoKVM" {
err := os.Remove(WebTitleFile)
if err != nil {
rsp.ErrRsp(c, -2, "reset failed")
return
}
} else {
err := os.WriteFile(WebTitleFile, []byte(req.Title), 0o644)
if err != nil {
rsp.ErrRsp(c, -3, "write failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("set web title: %s", req.Title)
}
func (s *Service) GetWebTitle(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(WebTitleFile)
if err != nil {
rsp.ErrRsp(c, -1, "read web title failed")
return
}
rsp.OkRspWithData(c, &proto.GetWebTitleRsp{
Title: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get web title successful")
}

View File

@@ -1,66 +0,0 @@
package vm
import (
"NanoKVM-Server/proto"
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
WebTitleFile = "/etc/kvm/web_title"
)
func init() {
if _, err := os.Stat(WebTitleFile); os.IsNotExist(err) {
err := ioutil.WriteFile(WebTitleFile, []byte("NanoKVM"), 0644)
if err != nil {
log.Errorf("Create Web Title file failed: %s", WebTitleFile)
return
}
log.Debugf("Create Web Title file: %s", WebTitleFile)
}
}
func (s *Service) SetWebTitle(c *gin.Context) {
var req proto.SetWebTitleReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
data := []byte(fmt.Sprintf("%s", req.WebTitle))
err := os.WriteFile(WebTitleFile, data, 0o644)
if err != nil {
rsp.ErrRsp(c, -2, "failed to write data")
return
}
rsp.OkRsp(c)
log.Debugf("set WebTitle: %s", req.WebTitle)
}
func (s *Service) GetWebTitle(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(WebTitleFile)
if err != nil {
rsp.ErrRsp(c, -1, "read WebTitle failed")
return
}
rsp.OkRspWithData(c, &proto.GetWebTitleRsp{
WebTitle: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get WebTitle successful")
}

View File

@@ -10,6 +10,7 @@ import (
log "github.com/sirupsen/logrus"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/vm/jiggler"
)
const (
@@ -85,6 +86,9 @@ func (c *WsClient) Read() error {
} else if event[0] == MouseEvent {
c.mouse <- event[1:]
}
// update latest HID operation time
jiggler.GetJiggler().Update()
}
}

View File

@@ -16,7 +16,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.21.6",
"axios": "^1.7.7",
"axios": "^1.8.4",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.4",
@@ -25,7 +25,7 @@
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.0",
"react-draggable": "^4.4.6",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.3",

22
web/pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^5.21.6
version: 5.21.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
axios:
specifier: ^1.7.7
version: 1.7.7
specifier: ^1.8.4
version: 1.8.4
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -51,7 +51,7 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-draggable:
specifier: ^4.4.0
specifier: ^4.4.6
version: 4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-error-boundary:
specifier: ^4.1.2
@@ -1118,8 +1118,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.7.7:
resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==}
axios@1.8.4:
resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1159,8 +1159,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
caniuse-lite@1.0.30001677:
resolution: {integrity: sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog==}
caniuse-lite@1.0.30001713:
resolution: {integrity: sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -3917,7 +3917,7 @@ snapshots:
autoprefixer@10.4.20(postcss@8.4.47):
dependencies:
browserslist: 4.24.2
caniuse-lite: 1.0.30001677
caniuse-lite: 1.0.30001713
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
@@ -3928,7 +3928,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.0.0
axios@1.7.7:
axios@1.8.4:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.1
@@ -3955,7 +3955,7 @@ snapshots:
browserslist@4.24.2:
dependencies:
caniuse-lite: 1.0.30001677
caniuse-lite: 1.0.30001713
electron-to-chromium: 1.5.51
node-releases: 2.0.18
update-browserslist-db: 1.1.1(browserslist@4.24.2)
@@ -3976,7 +3976,7 @@ snapshots:
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001677: {}
caniuse-lite@1.0.30001713: {}
chalk@4.1.2:
dependencies:

View File

@@ -22,8 +22,8 @@ export function deleteWolMac(mac: string) {
}
// set Mac name
export function setWolMacName(mac: string,name: string) {
return http.post('/api/network/wol/mac/setName', { mac, name });
export function setWolMacName(mac: string, name: string) {
return http.post('/api/network/wol/mac/name', { mac, name });
}
// get wifi information

View File

@@ -77,34 +77,24 @@ export function disableSSH() {
return http.post('/api/vm/ssh/disable');
}
// get Swap state
export function getSwapState() {
// get swap file size
export function getSwap() {
return http.get('/api/vm/swap');
}
// enable Swap
export function enableSwap() {
return http.post('/api/vm/swap/enable');
// set swap file size
export function setSwap(size: number) {
return http.post('/api/vm/swap', { size });
}
// disable Swap
export function disableSwap() {
return http.post('/api/vm/swap/disable');
// get mouse jiggler
export function getMouseJiggler() {
return http.get('/api/vm/mouse-jiggler');
}
// get MouseJiggler state
export function getMouseJigglerState() {
return http.get('/api/vm/mouseJiggler');
}
// enable MouseJiggler
export function enableMouseJiggler() {
return http.post('/api/vm/mouseJiggler/enable');
}
// disable MouseJiggler
export function disableMouseJiggler() {
return http.post('/api/vm/mouseJiggler/disable');
// set mouse jiggler
export function setMouseJiggler(enabled: boolean, mode: string) {
return http.post('/api/vm/mouse-jiggler', { enabled, mode });
}
// get Hostname
@@ -119,12 +109,12 @@ export function setHostname(hostname: string) {
// get WebTitle
export function getWebTitle() {
return http.get('/api/vm/webTitle');
return http.get('/api/vm/web-title');
}
// set WebTitle
export function setWebTitle(webTitle: string) {
return http.post('/api/vm/webTitle', { webTitle });
export function setWebTitle(title: string) {
return http.post('/api/vm/web-title', { title });
}
// get mDNS state

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Helmet, HelmetData } from 'react-helmet-async';
import * as api from '@/api/vm.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
type HeadProps = {
title?: string;
@@ -11,27 +11,12 @@ type HeadProps = {
const helmetData = new HelmetData({});
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
const [webTitle, setWebTitle] = useState("");
useEffect(() => {
api
.getWebTitle()
.then((rsp) => {
if (rsp.data?.webTitle) {
setWebTitle(rsp.data?.webTitle.toString());
} else {
setWebTitle("NanoKVM");
}
})
}, []);
const webTitle = useAtomValue(webTitleAtom);
return (
<Helmet
helmetData={helmetData}
title={title ? `${title} - ${webTitle}` : undefined}
title={webTitle ? webTitle : title ? `${title} - NanoKVM` : undefined}
defaultTitle={webTitle}
>
<meta name="description" content={description} />

View File

@@ -170,11 +170,16 @@ const en = {
},
power: {
title: 'Power',
showConfirm: 'Confirmation',
showConfirmTip: 'Power operations require an extra confirmation',
reset: 'Reset',
power: 'Power',
powerShort: 'Power (short click)',
powerLong: 'Power (long click)',
powerConfirm: 'Are you sure you want to perform this operation?'
resetConfirm: 'Proceed reset operation?',
powerConfirm: 'Proceed power operation?',
okBtn: 'Yes',
cancelBtn: 'No'
},
settings: {
title: 'Settings',
@@ -188,7 +193,9 @@ const en = {
image: 'Image Version',
imageTip: 'NanoKVM system image version',
deviceKey: 'Device Key',
community: 'Community'
community: 'Community',
hostname: 'Hostname',
hostnameUpdated: 'Hostname updated. Reboot to apply.'
},
appearance: {
title: 'Appearance',
@@ -196,12 +203,8 @@ const en = {
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar',
webTitle: {
title: 'Web Title',
description: 'NanoKVM Web Title',
success: 'Web Title changed successfully',
error: 'Web Title change failed'
},
webTitle: 'Web Title',
webTitleDesc: 'Customize the web page title'
},
device: {
title: 'Device',
@@ -227,26 +230,18 @@ const en = {
description: 'Enable SSH remote access',
tip: 'Set a strong password before enabling (Account - Change Password)'
},
advanced: 'Advanced Settings',
swap: {
description: 'Enable the swap partition',
tip: 'Enable the default swap partition(128M)'
},
hostname: {
title: 'Hostname',
description: 'NanoKVM Hostname',
tip: 'Need reboot to apply the change',
success: 'Hostname changed successfully',
error: 'Hostname change failed'
disable: 'Disable',
description: 'Set the swap file size',
tip: "Enabling this feature could shorten your SD card's usable life!"
},
mouseJiggler: {
title: 'Mouse Jiggler',
description: 'Enable the mouse jiggler',
tip: 'Automatically jiggles the mouse when inactive for 60 seconds to prevent system sleep'
},
powerConfirm: {
title: 'Power Operation Reconfirmation',
description: 'Enable power operation reconfirmation',
tip: 'When operating the power, a double confirmation dialog will pop up to prevent accidental touches'
description: 'Prevent the remote host from sleeping',
disable: 'Disable',
absolute: 'Absolute Mode',
relative: 'Relative Mode'
},
mdns: {
description: 'Enable mDNS discovery service',

View File

@@ -23,8 +23,6 @@ const zh = {
illegalUsername: '用户名中包含非法字符',
illegalPassword: '密码中包含非法字符',
forgetPassword: '忘记密码',
reboot: '重启',
rebootDesc: '你确定要重启NanoKVM吗',
ok: '确定',
cancel: '取消',
loginButtonText: '登录',
@@ -156,11 +154,16 @@ const zh = {
},
power: {
title: '电源',
showConfirm: '显示确认框',
showConfirmTip: '电源操作需要二次确认',
reset: '重启',
power: '电源',
powerShort: '电源(短按)',
powerLong: '电源(长按)',
powerConfirm: '你确定要执行此操作吗?'
resetConfirm: '确认执行重启操作吗?',
powerConfirm: '确认执行电源操作吗?',
okBtn: '确认',
cancelBtn: '取消'
},
settings: {
title: '设置',
@@ -174,7 +177,9 @@ const zh = {
image: '镜像版本',
imageTip: 'NanoKVM 系统镜像版本',
deviceKey: '设备码',
community: '社区'
community: '社区',
hostname: '主机名',
hostnameUpdated: '主机名修改成功,重启后生效'
},
appearance: {
title: '外观',
@@ -182,12 +187,8 @@ const zh = {
language: '语言',
menuBar: '菜单栏',
menuBarDesc: '是否在菜单栏中显示图标',
webTitle: {
title: '网站标题',
description: 'NanoKVM 网站标题',
success: '网站标题修改成功',
error: '网站标题修改失败'
},
webTitle: '网站标题',
webTitleDesc: '自定义网站页面标题'
},
device: {
title: '设备',
@@ -213,26 +214,18 @@ const zh = {
description: '启用 SSH 远程访问',
tip: '启用前请务必设置强密码(帐号 - 修改密码)'
},
advanced: '高级设置',
swap: {
description: '启用交换分区',
tip: '启用默认的交换分区128M'
},
mouseJiggler:{
disable: '禁用',
description: '设置交换文件大小',
tip: '启用该功能可能会减少SD卡使用寿命'
},
mouseJiggler: {
title: '鼠标抖动',
description: '启用鼠标抖动',
tip: '在鼠标离开60s未操作时自动进行抖动以防止系统睡眠'
},
hostname: {
title: 'Hostname',
description: 'NanoKVM 主机名',
tip: '修改后需要重启生效',
success: '主机名修改成功',
error: '主机名修改失败'
},
powerConfirm:{
title: '电源操作确认',
description: '启用电源操作二次确认',
tip: '在操作电源时弹出二次确认框以防误触'
description: '防止远程主机休眠',
disable: '关闭',
absolute: '绝对模式',
relative: '相对模式'
},
mdns: {
description: '启用 mDNS 发现服务',

View File

@@ -2,3 +2,6 @@ import { atom } from 'jotai';
// menu bar disabled items
export const menuDisabledItemsAtom = atom<string[]>([]);
// web title
export const webTitleAtom = atom('');

View File

@@ -167,11 +167,11 @@ export function getMenuDisabledItems(): string[] {
return value ? JSON.parse(value) : [];
}
export function getPowerConfirm(): string | null {
return getWithExpiry(POWER_CONFIRM_KEY);
export function getPowerConfirm() {
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
return enabled ? Boolean(enabled) : false;
}
export function setPowerConfirm(value: boolean) {
const expiry = 365 * 24 * 60 * 60 * 1000;
setWithExpiry(POWER_CONFIRM_KEY, String(value), expiry);
}
export function setPowerConfirm(enabled: boolean) {
localStorage.setItem(POWER_CONFIRM_KEY, String(enabled));
}

View File

@@ -12,6 +12,16 @@ import './i18n';
import './assets/styles/index.css';
const renderApp = () => {
const themeConfig = {
algorithm: theme.darkAlgorithm,
components: {
Collapse: {
headerPadding: 0,
contentPadding: 0
}
}
};
return ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Suspense
@@ -23,7 +33,7 @@ const renderApp = () => {
>
<ErrorBoundary FallbackComponent={MainError}>
<HelmetProvider>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
<ConfigProvider theme={themeConfig}>
<RouterProvider router={router} />
</ConfigProvider>
</HelmetProvider>
@@ -34,7 +44,7 @@ const renderApp = () => {
};
if (import.meta.env.MODE === 'mocked') {
const { worker } = await import('./mocks/browser')
const { worker } = await import('./mocks/browser');
worker.start().then(() => {
return renderApp();
});

View File

@@ -1,12 +1,14 @@
import { useEffect } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import { getWebTitle } from '@/api/vm.ts';
import { getResolution, getVideoMode } from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
import { Head } from '@/components/head.tsx';
import { Keyboard } from './keyboard';
@@ -23,6 +25,7 @@ export const Desktop = () => {
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const setWebTitle = useSetAtom(webTitleAtom);
useEffect(() => {
const cookieVideoMode = getVideoMode();
@@ -31,6 +34,12 @@ export const Desktop = () => {
const cookieResolution = getResolution();
setResolution(cookieResolution ? cookieResolution : { width: 0, height: 0 });
getWebTitle().then((rsp) => {
if (rsp.data?.title) {
setWebTitle(rsp.data.title);
}
});
const timer = setInterval(() => {
client.send([0]);
}, 60 * 1000);

View File

@@ -1,23 +1,31 @@
import { useEffect, useState } from 'react';
import { Popconfirm, Slider } from 'antd';
import { Divider, Switch, Tooltip } from 'antd';
import clsx from 'clsx';
import { CirclePowerIcon, LoaderCircleIcon, PowerIcon, RotateCcwIcon } from 'lucide-react';
import { LoaderCircleIcon, PowerIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getPowerConfirm } from '../../../../lib/localstorage'
import * as api from '@/api/vm';
import * as localstorage from '@/lib/localstorage.ts';
import { MenuItem } from '@/components/menu-item.tsx';
import { PowerLong } from './power-long.tsx';
import { PowerShort } from './power-short.tsx';
import { Reset } from './reset.tsx';
export const Power = () => {
const { t } = useTranslation();
const [isPowerOn, setIsPowerOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [powerDuration, setPowerDuration] = useState(8);
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
getLed();
const interval = setInterval(getLed, 5000);
const powerConfirm = localstorage.getPowerConfirm();
setShowConfirm(powerConfirm);
return () => {
clearInterval(interval);
};
@@ -25,35 +33,17 @@ export const Power = () => {
function getLed() {
api.getGpio().then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
if (rsp.code === 0) {
setIsPowerOn(rsp.data.pwr);
}
setIsPowerOn(rsp.data.pwr);
});
}
function click(button: 'reset' | 'power',type: string, duration?: number) {
if(getPowerConfirm() == 'true' && type == 'first') return;
if (isLoading) return;
setIsLoading(true);
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
api.setGpio(button, millisecond).finally(() => {
setIsLoading(false);
});
function updateShowConfirm(value: boolean) {
setShowConfirm(value);
localstorage.setPowerConfirm(value);
}
function onChange(value: number) {
setPowerDuration(value);
}
const icon = (
<div className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}>
{isLoading ? (
@@ -65,72 +55,27 @@ export const Power = () => {
);
const content = (
<div className="flex flex-col space-y-1">
<div className="min-w-[200px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('power.title')}</span>
<Popconfirm
placement="bottom"
title={t('power.reset')}
description={t('power.powerConfirm')}
okText={t('settings.device.okBtn')}
cancelText={t('settings.device.cancelBtn')}
onConfirm={() => click('reset', 'twice', 0.8)}
disabled={!(getPowerConfirm() == 'true')}
>
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
onClick={() => click('reset', 'first', 0.8)}
>
<RotateCcwIcon size={16} />
<span>{t('power.reset')}</span>
<div className="flex items-center space-x-2">
<Tooltip title={t('power.showConfirmTip')} placement="right">
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-400">{t('power.showConfirm')}</span>
<Switch size="small" checked={showConfirm} onChange={updateShowConfirm} />
</div>
</Tooltip>
</div>
</div>
</Popconfirm>
<Popconfirm
placement="bottom"
title={t('power.powerShort')}
description={t('power.powerConfirm')}
okText={t('settings.device.okBtn')}
cancelText={t('settings.device.cancelBtn')}
onConfirm={() => click('power', 'twice', 0.8)}
disabled={!(getPowerConfirm() == 'true')}
>
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
onClick={() => click('power', 'first', 0.8)}
>
<PowerIcon size={16} />
<span>{t('power.powerShort')}</span>
</div>
</Popconfirm>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Popconfirm
placement="bottom"
title={t('power.powerLong')}
description={t('power.powerConfirm')}
okText={t('settings.device.okBtn')}
cancelText={t('settings.device.cancelBtn')}
onConfirm={() => click('power', 'twice')}
disabled={!(getPowerConfirm() == 'true')}
>
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
onClick={() => click('power', 'first')}
>
<CirclePowerIcon size={16} />
<span>{t('power.powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{`${powerDuration}s`}</div>
</div>
</Popconfirm>
<div className="px-3">
<Slider
defaultValue={8}
min={1}
max={30}
tooltip={{ placement: 'bottom' }}
onChange={onChange}
/>
<div className="flex flex-col space-y-1">
<Reset showConfirm={showConfirm} isLoading={isLoading} setIsLoading={setIsLoading} />
<PowerShort showConfirm={showConfirm} isLoading={isLoading} setIsLoading={setIsLoading} />
<PowerLong showConfirm={showConfirm} isLoading={isLoading} setIsLoading={setIsLoading} />
</div>
</div>
);

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Popconfirm, Slider } from 'antd';
import { CirclePowerIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
type PowerLongProps = {
showConfirm: boolean;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
};
export const PowerLong = ({ showConfirm, isLoading, setIsLoading }: PowerLongProps) => {
const { t } = useTranslation();
const [duration, setDuration] = useState(8);
function power() {
if (isLoading) return;
setIsLoading(true);
api.setGpio('power', duration * 1000).finally(() => {
setIsLoading(false);
});
}
return (
<>
{showConfirm ? (
<Popconfirm
placement="bottomLeft"
title={t('power.powerConfirm')}
okText={t('power.okBtn')}
cancelText={t('power.cancelBtn')}
onConfirm={power}
color="#404040"
>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70">
<CirclePowerIcon size={16} />
<span>{t('power.powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{`${duration}s`}</div>
</div>
</Popconfirm>
) : (
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
onClick={power}
>
<CirclePowerIcon size={16} />
<span>{t('power.powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{`${duration}s`}</div>
</div>
)}
<div className="px-3">
<Slider
defaultValue={8}
min={1}
max={30}
tooltip={{ placement: 'bottom' }}
onChange={setDuration}
/>
</div>
</>
);
};

View File

@@ -0,0 +1,52 @@
import { Popconfirm } from 'antd';
import { PowerIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
type PowerShortProps = {
showConfirm: boolean;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
};
export const PowerShort = ({ showConfirm, isLoading, setIsLoading }: PowerShortProps) => {
const { t } = useTranslation();
function power() {
if (isLoading) return;
setIsLoading(true);
api.setGpio('power', 800).finally(() => {
setIsLoading(false);
});
}
return (
<>
{showConfirm ? (
<Popconfirm
placement="bottomLeft"
title={t('power.powerConfirm')}
okText={t('power.okBtn')}
cancelText={t('power.cancelBtn')}
onConfirm={power}
color="#404040"
>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70">
<PowerIcon size={16} />
<span>{t('power.powerShort')}</span>
</div>
</Popconfirm>
) : (
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
onClick={power}
>
<PowerIcon size={16} />
<span>{t('power.powerShort')}</span>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,52 @@
import { Popconfirm } from 'antd';
import { RotateCcwIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setGpio } from '@/api/vm.ts';
type ResetProps = {
showConfirm: boolean;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
};
export const Reset = ({ showConfirm, isLoading, setIsLoading }: ResetProps) => {
const { t } = useTranslation();
function reset() {
if (isLoading) return;
setIsLoading(true);
setGpio('reset', 800).finally(() => {
setIsLoading(false);
});
}
return (
<>
{showConfirm ? (
<Popconfirm
placement="bottomLeft"
title={t('power.resetConfirm')}
okText={t('power.okBtn')}
cancelText={t('power.cancelBtn')}
onConfirm={reset}
color="#404040"
>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70">
<RotateCcwIcon size={16} />
<span>{t('power.reset')}</span>
</div>
</Popconfirm>
) : (
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 hover:bg-neutral-700/70"
onClick={reset}
>
<RotateCcwIcon size={16} />
<span>{t('power.reset')}</span>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { useSetAtom } from 'jotai';
import { ClipboardPenIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Hostname = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isLoading, setIsLoading] = useState(false);
const [hostname, setHostname] = useState('');
const [editState, setEditState] = useState<'' | 'editing' | 'edited'>('');
const [input, setInput] = useState('');
useEffect(() => {
getHostname();
}, []);
function getHostname() {
setIsLoading(true);
api
.getHostname()
.then((rsp) => {
if (rsp.data?.hostname) {
setHostname(rsp.data?.hostname);
}
})
.finally(() => {
setIsLoading(false);
});
}
function showInput() {
setInput(hostname);
setEditState('editing');
}
function update() {
if (input === hostname) {
setEditState('');
return;
}
if (isLoading) return;
setIsLoading(true);
api
.setHostname(input)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setHostname(input);
setEditState('edited');
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div className="flex w-full items-center justify-between pt-4">
<span>{t('settings.about.hostname')}</span>
{editState === 'editing' ? (
<div className="flex items-center space-x-1">
<Input
disabled={isLoading}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
style={{ width: 150 }}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<Button size="small" icon={<CheckOutlined />} onClick={update} />
<Button size="small" icon={<CloseOutlined />} onClick={() => setEditState('')} />
</div>
) : (
<div className="flex items-start space-x-2">
<span>{hostname}</span>
<div
className="cursor-pointer text-blue-500 hover:text-blue-500/60"
onClick={showInput}
>
<ClipboardPenIcon size={18} />
</div>
</div>
)}
</div>
{editState === 'edited' && (
<div className="flex w-full justify-end pt-1 text-xs text-green-500">
{t('settings.about.hostnameUpdated')}
</div>
)}
</>
);
};

View File

@@ -2,6 +2,7 @@ import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { Community } from './community.tsx';
import { Hostname } from './hostname.tsx';
import { Information } from './information.tsx';
export const About = () => {
@@ -13,6 +14,7 @@ export const About = () => {
<Divider />
<Information />
<Hostname />
<Divider />
<Community />

View File

@@ -45,14 +45,14 @@ export const Information = () => {
<div className="flex w-full items-start justify-between">
<span>{t('settings.about.ip')}</span>
{information?.ips && information.ips.length > 0 ? (
<div className="flex flex-col space-y-1">
{information.ips.map((ip) => (
<div className="flex items-center space-x-1">
<span>{ip.addr}</span>
<Tag>{ip.type}</Tag>
</div>
))}
</div>
<div className="flex flex-col space-y-1">
{information.ips.map((ip) => (
<div key={ip.addr} className="flex items-center">
<Tag>{ip.type}</Tag>
<span>{ip.addr}</span>
</div>
))}
</div>
) : (
<span>-</span>
)}
@@ -99,10 +99,10 @@ export const Information = () => {
</div>
{/* device key */}
<div className="flex w-full items-center justify-between">
<span>{t('settings.about.deviceKey')}</span>
<span>{information ? information.deviceKey : '-'}</span>
</div>
{/*<div className="flex w-full items-center justify-between">*/}
{/* <span>{t('settings.about.deviceKey')}</span>*/}
{/* <span>{information ? information.deviceKey : '-'}</span>*/}
{/*</div>*/}
</div>
</>
);

View File

@@ -12,13 +12,14 @@ export const Appearance = () => {
<>
<div className="text-base font-bold">{t('settings.appearance.title')}</div>
<Divider />
<div className="flex flex-col space-y-6">
<Language />
<WebTitle />
</div>
<Divider />
<MenuBar />
<Divider />
<WebTitle />
</>
);
};

View File

@@ -21,22 +21,18 @@ export const Language = () => {
}
return (
<>
<div className="pb-5 text-neutral-400">{t('settings.appearance.display')}</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<LanguagesIcon size={16} />
<span>{t('settings.appearance.language')}</span>
</div>
<Select
defaultValue={i18n.language}
style={{ width: 180 }}
options={options}
onSelect={changeLanguage}
/>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<LanguagesIcon size={16} />
<span>{t('settings.appearance.language')}</span>
</div>
</>
<Select
defaultValue={i18n.language}
style={{ width: 180 }}
options={options}
onSelect={changeLanguage}
/>
</div>
);
};

View File

@@ -1,65 +1,73 @@
import { useEffect, useState } from 'react';
import { Input, message } from 'antd';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { Input } from 'antd';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { LoadingOutlined } from '@ant-design/icons';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
export const WebTitle = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setWebTitle = useSetAtom(webTitleAtom);
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isLoading, setIsLoading] = useState(false);
const [webTitle, setWebTitle] = useState("");
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
setIsLoading(true);
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('NanoKVM');
api
.getWebTitle()
.then((rsp) => {
if (rsp.data?.webTitle) {
setWebTitle(rsp.data?.webTitle.toString());
}
})
.finally(() => {
setIsLoading(false);
});
}, []);
useEffect(() => {
setIsLoading(true);
async function update() {
if (isLoading) return;
setIsLoading(true);
api
.getWebTitle()
.then((rsp) => {
if (rsp.data?.title) {
setTitle(rsp.data.title);
setWebTitle(rsp.data.title);
}
})
.finally(() => {
setIsLoading(false);
});
}, []);
const rsp = await api.setWebTitle(webTitle);
function update() {
if (isLoading) return;
setIsLoading(true);
api
.setWebTitle(title)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
messageApi.error(t('settings.appearance.webTitle.error') + ":" + rsp.msg);
return;
}else{
messageApi.success(t('settings.appearance.webTitle.success'));
console.log(rsp.msg);
return;
}
setWebTitle(title === 'NanoKVM' ? '' : title);
})
.finally(() => {
setIsLoading(false);
}
});
}
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<span>{t('settings.appearance.webTitle.title')}</span>
<span>{t('settings.appearance.webTitle')}</span>
</div>
<span className="text-xs text-neutral-500">{t('settings.appearance.webTitle.description')}</span>
<span className="text-xs text-neutral-500">{t('settings.appearance.webTitleDesc')}</span>
</div>
{!isLoading ? <Input onFocus={() => setIsKeyboardEnable(false)} onBlur={() => setIsKeyboardEnable(true)} style={{ width: 180 }} value={webTitle} onChange={(e) => setWebTitle(e.target.value)} onPressEnter={update} /> : <LoadingOutlined />}
{contextHolder}
</div>
<Input
disabled={isLoading}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
style={{ width: 180 }}
value={title}
onChange={(e) => setTitle(e.target.value)}
onPressEnter={update}
/>
</div>
);
};

View File

@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { Switch, Tooltip } from 'antd';
import { CircleAlertIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setPowerConfirm, getPowerConfirm } from '../../../../../lib/localstorage';
export const PowerConfirm = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
const cookieValue = getPowerConfirm();
if (cookieValue === 'true') {
setIsEnabled(true);
}
setIsLoading(false);
}, []);
function update() {
if (isLoading) return;
setIsLoading(true);
const newValue = !isEnabled;
setPowerConfirm(newValue);
setIsLoading(false);
setIsEnabled(!isEnabled);
}
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<span>{t('settings.device.powerConfirm.title')}</span>
<Tooltip
title={t('settings.device.powerConfirm.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
>
<CircleAlertIcon size={15} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.powerConfirm.description')}</span>
</div>
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
</div>
);
};

View File

@@ -0,0 +1,22 @@
import { Collapse } from 'antd';
import { useTranslation } from 'react-i18next';
import { Swap } from './swap.tsx';
const children = (
<div className="py-3">
<Swap />
</div>
);
export const Advanced = () => {
const { t } = useTranslation();
return (
<Collapse
ghost
expandIconPosition="end"
items={[{ key: 'advanced', label: t('settings.device.advanced'), children }]}
/>
);
};

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Switch, Tooltip } from 'antd';
import { Select, Tooltip } from 'antd';
import { CircleAlertIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -8,17 +8,25 @@ import * as api from '@/api/vm.ts';
export const Swap = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [size, setSize] = useState('0');
const options = [
{ value: '0', label: t('settings.device.swap.disable') },
{ value: '64', label: '64 MB' },
{ value: '128', label: '128 MB' },
{ value: '256', label: '256 MB' },
{ value: '512', label: '512 MB' }
];
useEffect(() => {
setIsLoading(true);
api
.getSwapState()
.getSwap()
.then((rsp) => {
if (rsp.data?.enabled) {
setIsEnabled(true);
if (rsp.data?.size) {
setSize(rsp.data.size.toString());
}
})
.finally(() => {
@@ -26,19 +34,23 @@ export const Swap = () => {
});
}, []);
async function update() {
async function update(value: string) {
if (isLoading) return;
setIsLoading(true);
const rsp = isEnabled ? await api.disableSwap() : await api.enableSwap();
setIsLoading(false);
api
.setSwap(parseInt(value))
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsEnabled(!isEnabled);
setSize(value);
})
.finally(() => {
setIsLoading(false);
});
}
return (
@@ -56,10 +68,16 @@ export const Swap = () => {
<CircleAlertIcon size={15} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.swap.description')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.swap.description')}</span>
</div>
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
<Select
style={{ width: 150 }}
value={size}
options={options}
loading={isLoading}
onChange={update}
/>
</div>
);
};

View File

@@ -1,75 +0,0 @@
import { useEffect, useState } from 'react';
import { Input, Tooltip, message } from 'antd';
import { useTranslation } from 'react-i18next';
import { CircleAlertIcon } from 'lucide-react';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { useSetAtom } from 'jotai';
import * as api from '@/api/vm.ts';
import { LoadingOutlined } from '@ant-design/icons';
export const Hostname = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isLoading, setIsLoading] = useState(false);
const [hostname, setHostname] = useState("");
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
setIsLoading(true);
api
.getHostname()
.then((rsp) => {
if (rsp.data?.hostname) {
setHostname(rsp.data?.hostname.toString());
}
})
.finally(() => {
setIsLoading(false);
});
}, []);
async function update() {
if (isLoading) return;
setIsLoading(true);
const rsp = await api.setHostname(hostname);
if (rsp.code !== 0) {
console.log(rsp.msg);
messageApi.error(t('settings.device.hostname.error') + ":" + rsp.msg);
return;
}else{
messageApi.success(t('settings.device.hostname.success'));
}
setIsLoading(false);
}
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<span>{t('settings.device.hostname.title')}</span>
<Tooltip
title={t('settings.device.hostname.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
>
<CircleAlertIcon size={15} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.hostname.description')}</span>
</div>
{!isLoading ? <Input onFocus={() => setIsKeyboardEnable(false)} onBlur={() => setIsKeyboardEnable(true)} style={{ width: 150 }} value={hostname} onChange={(e) => setHostname(e.target.value)} onPressEnter={update} /> : <LoadingOutlined />}
{contextHolder}
</div>
);
};

View File

@@ -6,17 +6,15 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { hidModeAtom } from '@/jotai/mouse.ts';
import { Advanced } from './advanced';
import { HidMode } from './hid-mode.tsx';
import { Mdns } from './mdns.tsx';
import { MouseJiggler } from './mouse-jiggler.tsx';
import { Oled } from './oled.tsx';
import { Reboot } from './reboot.tsx';
import { Ssh } from './ssh.tsx';
import { VirtualDevices } from './virtual-devices.tsx';
import { Wifi } from './wifi.tsx';
import { Swap } from './swap.tsx';
import { MouseJiggler } from './mouse-jiggler.tsx';
import { PowerConfirm } from './PowerConfirm.tsx';
import { Hostname } from './hostname.tsx';
export const Device = () => {
const { t } = useTranslation();
@@ -38,17 +36,17 @@ export const Device = () => {
<div className="flex flex-col space-y-6">
<Oled />
<Hostname />
<Wifi />
<Ssh />
<Mdns />
<Swap />
<MouseJiggler />
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
<PowerConfirm />
<MouseJiggler />
</div>
<Divider />
<Advanced />
<Divider />
<Reboot />
</>
);

View File

@@ -1,65 +1,107 @@
import { useEffect, useState } from 'react';
import { Switch, Tooltip } from 'antd';
import { CircleAlertIcon } from 'lucide-react';
import { Select, Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { setMouseJiggler } from '@/api/vm.ts';
export const MouseJiggler = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [enabled, setEnabled] = useState(false);
const [mode, setMode] = useState('relative');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getMouseJiggler();
}, []);
const options = [
{ value: 'disable', label: t('settings.device.mouseJiggler.disable') },
{ value: 'relative', label: t('settings.device.mouseJiggler.relative') },
{ value: 'absolute', label: t('settings.device.mouseJiggler.absolute') }
];
function getMouseJiggler() {
setIsLoading(true);
api
.getMouseJigglerState()
.getMouseJiggler()
.then((rsp) => {
if (rsp.data?.enabled) {
setIsEnabled(true);
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setEnabled(rsp.data.enabled);
setMode(rsp.data.mode);
})
.finally(() => {
setIsLoading(false);
});
}, []);
}
async function update() {
function enable() {
if (isLoading) return;
setIsLoading(true);
const rsp = isEnabled ? await api.disableMouseJiggler() : await api.enableMouseJiggler();
setIsLoading(false);
api
.setMouseJiggler(true, mode)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setEnabled(true);
})
.finally(() => {
setIsLoading(false);
});
}
setIsEnabled(!isEnabled);
function updateMode(value: string) {
if (isLoading) return;
setIsLoading(true);
const _enabled = value !== 'disable';
const _mode = value === 'disable' ? 'relative' : value;
setMouseJiggler(_enabled, _mode)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setEnabled(_enabled);
setMode(_mode);
})
.finally(() => {
setIsLoading(false);
});
}
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<span>{t('settings.device.mouseJiggler.title')}</span>
<Tooltip
title={t('settings.device.mouseJiggler.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
>
<CircleAlertIcon size={15} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.mouseJiggler.description')}</span>
<span>{t('settings.device.mouseJiggler.title')}</span>
<span className="text-xs text-neutral-500">
{t('settings.device.mouseJiggler.description')}
</span>
</div>
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
{enabled ? (
<Select
style={{ width: 150 }}
value={mode}
options={options}
loading={isLoading}
onChange={updateMode}
/>
) : (
<Switch checked={enabled} loading={isLoading} onChange={enable} />
)}
</div>
);
};

View File

@@ -107,7 +107,7 @@ export const Settings = () => {
<Modal
open={isModalOpen}
width={890}
width={900}
footer={null}
destroyOnClose={true}
styles={{ content: { padding: 0 } }}
@@ -145,7 +145,7 @@ export const Settings = () => {
</div>
<div className="flex max-h-[700px] w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3 sm:w-4/5">
<div className="w-full max-w-[500px] py-10">
<div className="w-full max-w-[550px] py-10">
<>{tabs.find((tab) => tab.id === currentTab)?.component}</>
</div>
</div>

View File

@@ -56,15 +56,15 @@ export const Wol = () => {
const isEdit = false;
const macList = rsp.data.macs
.filter((item: string) => item.trim() !== '')
.map((item: string) => {
const parts = item.split(" ");
const isName = parts.length > 1;
const name = isName ? parts[1] : '';
const mac = parts[0];
const isShow = !isName;
return { name, mac, isShow, isName, isEdit};
});
.filter((item: string) => item.trim() !== '')
.map((item: string) => {
const parts = item.split(' ');
const isName = parts.length > 1;
const name = isName ? parts[1] : '';
const mac = parts[0];
const isShow = !isName;
return { name, mac, isShow, isName, isEdit };
});
setMacList([]);
setMacList(macList);
@@ -73,26 +73,20 @@ export const Wol = () => {
function toggleShow(mac: string) {
setMacList(
macList.map((item) =>
item.mac === mac ? { ...item, isShow: !item.isShow } : item
)
macList.map((item) => (item.mac === mac ? { ...item, isShow: !item.isShow } : item))
);
}
function editMac(mac: string,isEdit: boolean) {
setMacList(
macList.map((item) =>
item.mac === mac ? { ...item, isEdit: !isEdit } : item
)
);
function editMac(mac: string, isEdit: boolean) {
setMacList(macList.map((item) => (item.mac === mac ? { ...item, isEdit: !isEdit } : item)));
}
async function setMacName(e: React.KeyboardEvent<HTMLInputElement>, mac: string) {
const value: string = e.currentTarget.value;
const rsp = await setWolMacName(mac,value);
const rsp = await setWolMacName(mac, value);
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
console.log(rsp.msg);
return;
}
getMacs();
}
@@ -100,7 +94,7 @@ export const Wol = () => {
function deleteMac(mac: string) {
deleteWolMac(mac).then((rsp) => {
if (rsp.code === 0) {
getMacs();
getMacs();
}
});
}
@@ -129,7 +123,7 @@ export const Wol = () => {
})
.catch(() => {
setStatus('failed');
});
});
}
const content = (
@@ -142,7 +136,7 @@ export const Wol = () => {
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} onPressEnter={() => wake()}/>
<Input ref={inputRef} value={input} onChange={handleChange} onPressEnter={() => wake()} />
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
@@ -168,19 +162,32 @@ export const Wol = () => {
renderItem={(item) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">
{item.isEdit ? <Input onFocus={() => setIsKeyboardEnable(false)} onBlur={() => setIsKeyboardEnable(true)} defaultValue={item.name} onPressEnter={(e) => setMacName(e, item.mac)} />:(item.isShow ? item.mac : item.name)}
{item.isEdit ? (
<Input
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
defaultValue={item.name}
onPressEnter={(e) => setMacName(e, item.mac)}
/>
) : item.isShow ? (
item.mac
) : (
item.name
)}
</div>
<div className="flex items-center space-x-1">
{item.isName && <div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-500 hover:bg-neutral-700/80"
onClick={() => toggleShow(item.mac)}
>
{item.isShow ? <EyeClosed size={16} /> : <Eye size={16} />}
</div>}
{item.isName && (
<div
className="text-500 flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700/80"
onClick={() => toggleShow(item.mac)}
>
{item.isShow ? <EyeClosed size={16} /> : <Eye size={16} />}
</div>
)}
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-500 hover:bg-neutral-700"
onClick={() => editMac(item.mac,item.isEdit)}
className="text-500 flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700"
onClick={() => editMac(item.mac, item.isEdit)}
>
<Pencil size={16} />
</div>

View File

@@ -56,7 +56,7 @@ export const H264 = () => {
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string);
if (!msg) return;
if (!msg?.data) return;
const data = JSON.parse(msg.data);
if (!data) return;
@@ -78,6 +78,14 @@ export const H264 = () => {
}
};
const heartbeatTimer = setInterval(() => {
try {
ws.send(JSON.stringify({ event: 'heartbeat', data: '' }));
} catch (err) {
console.log(err);
}
}, 60 * 1000);
setTimeout(() => {
setIsLoading(false);
}, 15 * 1000);
@@ -85,6 +93,9 @@ export const H264 = () => {
return () => {
ws.close();
pc.close();
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
};
}, []);
@@ -93,7 +104,7 @@ export const H264 = () => {
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
<video
id="screen"
className={clsx('block min-h-[240px] min-w-[380px] select-none', mouseStyle)}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={
resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }

View File

@@ -26,7 +26,7 @@ export const Mjpeg = () => {
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
<Image
id="screen"
className={clsx('block min-h-[240px] min-w-[380px] select-none', mouseStyle)}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={
resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }