mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -138,6 +138,9 @@ func (c *Client) readMessage() {
|
||||
return
|
||||
}
|
||||
|
||||
case "heartbeat":
|
||||
_ = c.sendMessage("heartbeat", "")
|
||||
|
||||
default:
|
||||
log.Debugf("unhandled message event: %s", message.Event)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
119
server/service/vm/jiggler/jiggler.go
Normal file
119
server/service/vm/jiggler/jiggler.go
Normal 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
|
||||
}
|
||||
55
server/service/vm/jiggler/mouse.go
Normal file
55
server/service/vm/jiggler/mouse.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
49
server/service/vm/mouse-jiggler.go
Normal file
49
server/service/vm/mouse-jiggler.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
58
server/service/vm/web-title.go
Normal file
58
server/service/vm/web-title.go
Normal 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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
22
web/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 发现服务',
|
||||
|
||||
@@ -2,3 +2,6 @@ import { atom } from 'jotai';
|
||||
|
||||
// menu bar disabled items
|
||||
export const menuDisabledItemsAtom = atom<string[]>([]);
|
||||
|
||||
// web title
|
||||
export const webTitleAtom = atom('');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
67
web/src/pages/desktop/menu/power/power-long.tsx
Normal file
67
web/src/pages/desktop/menu/power/power-long.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
web/src/pages/desktop/menu/power/power-short.tsx
Normal file
52
web/src/pages/desktop/menu/power/power-short.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
web/src/pages/desktop/menu/power/reset.tsx
Normal file
52
web/src/pages/desktop/menu/power/reset.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
108
web/src/pages/desktop/menu/settings/about/hostname.tsx
Normal file
108
web/src/pages/desktop/menu/settings/about/hostname.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user