mirror of https://github.com/jetkvm/kvm.git
feat: Add support for remove KVM switches
This commit is contained in:
parent
65e4a58ad9
commit
cd78bb5ada
|
@ -34,6 +34,10 @@ type Config struct {
|
||||||
TLSMode string `json:"tls_mode"`
|
TLSMode string `json:"tls_mode"`
|
||||||
UsbConfig *usbgadget.Config `json:"usb_config"`
|
UsbConfig *usbgadget.Config `json:"usb_config"`
|
||||||
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
UsbDevices *usbgadget.Devices `json:"usb_devices"`
|
||||||
|
// Remote KVM
|
||||||
|
RemoteKvmEnabled bool `json:"remote_kvm_enabled"`
|
||||||
|
RemoteKvmSelectedChannel string `json:"remote_kvm_selected_channel"`
|
||||||
|
RemoteKvmChannels []SwitchChannel `json:"remote_kvm_channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const configPath = "/userdata/kvm_config.json"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
|
|
66
jsonrpc.go
66
jsonrpc.go
|
@ -737,6 +737,65 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
||||||
return *config.UsbDevices, nil
|
return *config.UsbDevices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetKvmSwitchEnabled() (bool, error) {
|
||||||
|
return config.RemoteKvmEnabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetKvmSwitchEnabled(enabled bool) error {
|
||||||
|
config.RemoteKvmEnabled = enabled
|
||||||
|
config.RemoteKvmSelectedChannel = ""
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetKvmSwitchSelectedChannel() (*SwitchChannel, error) {
|
||||||
|
if !config.RemoteKvmEnabled {
|
||||||
|
return nil, fmt.Errorf("KVM switch is disabled")
|
||||||
|
}
|
||||||
|
if config.RemoteKvmSelectedChannel == "" {
|
||||||
|
return nil, fmt.Errorf("no channel selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range config.RemoteKvmChannels {
|
||||||
|
if c.Id == config.RemoteKvmSelectedChannel {
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("channel not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetKvmSwitchSelectedChannel(id string) error {
|
||||||
|
// Check that the channel is known (exists in the config)
|
||||||
|
err := RemoteKvmSwitchChannel(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to select channel by ID %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.RemoteKvmSelectedChannel = id
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetKvmSwitchChannels() ([]SwitchChannel, error) {
|
||||||
|
if !config.RemoteKvmEnabled {
|
||||||
|
return nil, fmt.Errorf("KVM switch is disabled")
|
||||||
|
}
|
||||||
|
return config.RemoteKvmChannels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetKvmSwitchChannels(newConfig SwitchChannelConfig) error {
|
||||||
|
config.RemoteKvmChannels = newConfig.Channels
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func updateUsbRelatedConfig() error {
|
func updateUsbRelatedConfig() error {
|
||||||
if err := gadget.UpdateGadgetConfig(); err != nil {
|
if err := gadget.UpdateGadgetConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to write gadget config: %w", err)
|
return fmt.Errorf("failed to write gadget config: %w", err)
|
||||||
|
@ -857,4 +916,11 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||||
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
|
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
|
||||||
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
|
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
|
||||||
|
// remote KVM
|
||||||
|
"getKvmSwitchEnabled": {Func: rpcGetKvmSwitchEnabled},
|
||||||
|
"setKvmSwitchEnabled": {Func: rpcSetKvmSwitchEnabled, Params: []string{"enabled"}},
|
||||||
|
"getKvmSwitchChannels": {Func: rpcGetKvmSwitchChannels},
|
||||||
|
"setKvmSwitchChannels": {Func: rpcSetKvmSwitchChannels, Params: []string{"config"}},
|
||||||
|
"getKvmSwitchSelectedChannel": {Func: rpcGetKvmSwitchSelectedChannel},
|
||||||
|
"setKvmSwitchSelectedChannel": {Func: rpcSetKvmSwitchSelectedChannel, Params: []string{"id"}},
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,263 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SwitchChannelCommandProtocol is the protocol used to connect to the remote KVM switch
|
||||||
|
type SwitchChannelCommandProtocol string
|
||||||
|
|
||||||
|
// SwitchChannelCommandFormat is the format of the commands (hex, base64, ascii)
|
||||||
|
type SwitchChannelCommandFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SwitchChannelCommandProtocolTCP SwitchChannelCommandProtocol = "tcp"
|
||||||
|
SwitchChannelCommandProtocolUDP SwitchChannelCommandProtocol = "udp"
|
||||||
|
SwitchChannelCommandProtocolHTTP SwitchChannelCommandProtocol = "http"
|
||||||
|
SwitchChannelCommandProtocolHTTPs SwitchChannelCommandProtocol = "https"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SwitchChannelCommandFormatHEX SwitchChannelCommandFormat = "hex"
|
||||||
|
SwitchChannelCommandFormatBase64 SwitchChannelCommandFormat = "base64"
|
||||||
|
SwitchChannelCommandFormatASCII SwitchChannelCommandFormat = "ascii"
|
||||||
|
SwitchChannelCommandFormatHTTP SwitchChannelCommandFormat = "http-raw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SwitchChannelCommand represents a command to be sent to a remote KVM switch
|
||||||
|
type SwitchChannelCommand struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Protocol SwitchChannelCommandProtocol `json:"protocol"`
|
||||||
|
Format SwitchChannelCommandFormat `json:"format"`
|
||||||
|
Commands string `json:"commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchChannel represents a remote KVM switch channel
|
||||||
|
type SwitchChannel struct {
|
||||||
|
Commands []SwitchChannelCommand `json:"commands"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchChannelConfig represents the remote KVM switch configuration
|
||||||
|
type SwitchChannelConfig struct {
|
||||||
|
Channels []SwitchChannel `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteKvmSwitchChannelRawIP(channel *SwitchChannel, idx int, command *SwitchChannelCommand) error {
|
||||||
|
var err error
|
||||||
|
var payloadBytes = make([][]byte, 0)
|
||||||
|
|
||||||
|
// Parse commands
|
||||||
|
switch command.Format {
|
||||||
|
case SwitchChannelCommandFormatHEX:
|
||||||
|
// Split by comma and parse as HEX
|
||||||
|
for _, cmd := range strings.Split(command.Commands, ",") {
|
||||||
|
// Parse HEX
|
||||||
|
b, err := hex.DecodeString(strings.TrimSpace(cmd))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid command provided for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
payloadBytes = append(payloadBytes, b)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case SwitchChannelCommandFormatBase64:
|
||||||
|
// Split by comma and parse as Base64
|
||||||
|
for _, cmd := range strings.Split(command.Commands, ",") {
|
||||||
|
// Parse Base64
|
||||||
|
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cmd))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid command provided for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
payloadBytes = append(payloadBytes, b)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case SwitchChannelCommandFormatASCII:
|
||||||
|
// Split by newline and parse as ASCII
|
||||||
|
for _, cmd := range strings.Split(command.Commands, "\n") {
|
||||||
|
// Parse ASCII
|
||||||
|
b := []byte(strings.TrimSpace(cmd))
|
||||||
|
payloadBytes = append(payloadBytes, b)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid format provided for %s command #%d: %s", command.Protocol, idx, command.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the address
|
||||||
|
var conn net.Conn
|
||||||
|
switch command.Protocol {
|
||||||
|
case SwitchChannelCommandProtocolTCP:
|
||||||
|
conn, err = net.Dial("tcp", command.Address)
|
||||||
|
break
|
||||||
|
case SwitchChannelCommandProtocolUDP:
|
||||||
|
conn, err = net.Dial("udp", command.Address)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid protocol provided for command #%d: %s", idx, command.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to address for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("failed to connect to address for command #%d: connection is nil", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Send commands
|
||||||
|
for _, b := range payloadBytes {
|
||||||
|
_, err := conn.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send command for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the connection
|
||||||
|
err = conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close connection for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteKvmSwitchChannelHttps(channel *SwitchChannel, idx int, command *SwitchChannelCommand) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
scheme := string(command.Protocol)
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return fmt.Errorf("invalid protocol provided for command #%d: %s", idx, command.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.Format != SwitchChannelCommandFormatHTTP {
|
||||||
|
return fmt.Errorf("invalid format provided for %s command #%d: %s", command.Protocol, idx, command.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpPayload := command.Commands
|
||||||
|
// If there is no \r\n at then end - add
|
||||||
|
if !strings.HasSuffix(httpPayload, "\r\n\r\n") {
|
||||||
|
if strings.HasSuffix(httpPayload, "\r\n") {
|
||||||
|
httpPayload += "\r\n"
|
||||||
|
} else {
|
||||||
|
httpPayload += "\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
requestReader := bufio.NewReader(strings.NewReader(httpPayload))
|
||||||
|
r, err := http.ReadRequest(requestReader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read request for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
r.RequestURI, r.URL.Scheme, r.URL.Host = "", scheme, r.Host
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read data to buffer
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err = io.Copy(&buf, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the response
|
||||||
|
defer func() {
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 || resp.StatusCode < 200 {
|
||||||
|
if buf.Len() > 0 {
|
||||||
|
return fmt.Errorf("failed to send request for command #%d: %s: %s", idx, resp.Status, buf.String())
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to send request for command #%d: %s", idx, resp.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteKvmSwitchChannel sends commands to a remote KVM switch
|
||||||
|
func RemoteKvmSwitchChannel(id string) error {
|
||||||
|
if !config.RemoteKvmEnabled {
|
||||||
|
return fmt.Errorf("remote KVM is not enabled")
|
||||||
|
}
|
||||||
|
if len(config.RemoteKvmChannels) == 0 {
|
||||||
|
return fmt.Errorf("no remote KVM channels configured")
|
||||||
|
}
|
||||||
|
if len(id) == 0 {
|
||||||
|
return fmt.Errorf("no channel id provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel *SwitchChannel
|
||||||
|
|
||||||
|
for _, c := range config.RemoteKvmChannels {
|
||||||
|
if c.Id == id {
|
||||||
|
channel = &c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel == nil {
|
||||||
|
return fmt.Errorf("channel not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to run commands
|
||||||
|
if len(channel.Commands) == 0 {
|
||||||
|
return fmt.Errorf("no commands found for channel %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, c := range channel.Commands {
|
||||||
|
// Initial validation
|
||||||
|
if c.Protocol == SwitchChannelCommandProtocolTCP || c.Protocol == SwitchChannelCommandProtocolUDP {
|
||||||
|
if c.Address == "" {
|
||||||
|
return fmt.Errorf("no address provided for command #%d", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := net.SplitHostPort(c.Address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid address provided for command #%d: %w", idx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Protocol == "" {
|
||||||
|
return fmt.Errorf("no protocol provided for command #%d", idx)
|
||||||
|
}
|
||||||
|
if c.Format == "" {
|
||||||
|
return fmt.Errorf("no format provided for command #%d", idx)
|
||||||
|
}
|
||||||
|
if c.Commands == "" {
|
||||||
|
return fmt.Errorf("no commands provided for command #%d", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case c.Protocol == SwitchChannelCommandProtocolTCP || c.Protocol == SwitchChannelCommandProtocolUDP:
|
||||||
|
return remoteKvmSwitchChannelRawIP(channel, idx, &c)
|
||||||
|
case c.Protocol == SwitchChannelCommandProtocolHTTPs || c.Protocol == SwitchChannelCommandProtocolHTTP:
|
||||||
|
return remoteKvmSwitchChannelHttps(channel, idx, &c)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid protocol provided for command #%d: %s", idx, c.Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -34,6 +34,7 @@
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.12.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
|
@ -6211,6 +6212,18 @@
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/validator": {
|
"node_modules/validator": {
|
||||||
"version": "13.12.0",
|
"version": "13.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.12.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
import { LuCable, LuHardDrive, LuMaximize, LuMerge, LuSettings, LuSignal } from "react-icons/lu";
|
||||||
import { FaKeyboard } from "react-icons/fa6";
|
import { FaKeyboard } from "react-icons/fa6";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { Fragment, useCallback, useEffect, useRef } from "react";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
|
@ -18,13 +18,16 @@ import PasteModal from "@/components/popovers/PasteModal";
|
||||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||||
import MountPopopover from "@/components/popovers/MountPopover";
|
import MountPopopover from "@/components/popovers/MountPopover";
|
||||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||||
|
import SelectChannelPopover from "@/components/popovers/SelectChannelPopover";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
}: {
|
}: {
|
||||||
requestFullscreen: () => Promise<void>;
|
requestFullscreen: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const [send] = useJsonRpc();
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
|
|
||||||
|
@ -33,6 +36,10 @@ export default function Actionbar({
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const terminalType = useUiStore(state => state.terminalType);
|
const terminalType = useUiStore(state => state.terminalType);
|
||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||||
|
const remoteKvmEnabled = useUiStore(state => state.remoteKvmEnabled);
|
||||||
|
const setRemoteKvmEnabled = useUiStore(state => state.setRemoteKvmEnabled);
|
||||||
|
const remoteKvmSelectedChannel = useUiStore(state => state.remoteKvmSelectedChannel);
|
||||||
|
const setRemoteKvmSelectedChannel = useUiStore(state => state.setRemoteKvmSelectedChannel);
|
||||||
const remoteVirtualMediaState = useMountMediaStore(
|
const remoteVirtualMediaState = useMountMediaStore(
|
||||||
state => state.remoteVirtualMediaState,
|
state => state.remoteVirtualMediaState,
|
||||||
);
|
);
|
||||||
|
@ -56,6 +63,28 @@ export default function Actionbar({
|
||||||
[setDisableFocusTrap],
|
[setDisableFocusTrap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getKvmSwitchEnabled", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRemoteKvmEnabled(Boolean(resp.result));
|
||||||
|
|
||||||
|
send("getKvmSwitchSelectedChannel", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = resp.result as any;
|
||||||
|
setRemoteKvmSelectedChannel({
|
||||||
|
name: data.name,
|
||||||
|
id: data.id
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||||
<div
|
<div
|
||||||
|
@ -207,6 +236,44 @@ export default function Actionbar({
|
||||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{remoteKvmEnabled && (
|
||||||
|
<div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton as={Fragment}>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text={!!remoteKvmSelectedChannel ? `Channel: ${remoteKvmSelectedChannel.name}` : "Select channel"}
|
||||||
|
LeadingIcon={LuMerge}
|
||||||
|
onClick={() => {
|
||||||
|
setDisableFocusTrap(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverButton>
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
transition
|
||||||
|
style={{
|
||||||
|
transitionProperty: "opacity",
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
|
||||||
|
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ open }) => {
|
||||||
|
checkIfStateChanged(open);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<SelectChannelPopover />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useClose } from "@headlessui/react";
|
||||||
|
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import { RemoteKVMSwitchSelectedChannel, useUiStore } from "@/hooks/stores";
|
||||||
|
|
||||||
|
|
||||||
|
export default function SelectChannelPopover() {
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
const [switchChannelNames, setSwitchChannelNames] = useState<RemoteKVMSwitchSelectedChannel[]>([]);
|
||||||
|
const remoteKvmSelectedChannel = useUiStore(state => state.remoteKvmSelectedChannel);
|
||||||
|
const setRemoteKvmSelectedChannel = useUiStore(state => state.setRemoteKvmSelectedChannel);
|
||||||
|
const close = useClose();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getKvmSwitchChannels", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to get switch channels: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSwitchChannelNames((resp.result as { name: string, id: string }[]).map(x => ({ name: x.name, id: x.id })));
|
||||||
|
})
|
||||||
|
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const onChannelSelected = useCallback((selection: RemoteKVMSwitchSelectedChannel | null) => {
|
||||||
|
if (selection === null) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedItem = selection!;
|
||||||
|
|
||||||
|
send("setKvmSwitchSelectedChannel", { id: selectedItem.id }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to set switch channel: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.success(`Remote KVM switch set to channel ${selectedItem.name}`);
|
||||||
|
setRemoteKvmSelectedChannel(selectedItem);
|
||||||
|
close();
|
||||||
|
})
|
||||||
|
}, [send, close, setRemoteKvmSelectedChannel]);
|
||||||
|
|
||||||
|
const onChannelSelectedById = useCallback((id: string) => {
|
||||||
|
onChannelSelected(switchChannelNames.find(x => x.id === id) || null);
|
||||||
|
}, [onChannelSelected, switchChannelNames]);
|
||||||
|
|
||||||
|
let options = []
|
||||||
|
|
||||||
|
if (!remoteKvmSelectedChannel) {
|
||||||
|
options.push({
|
||||||
|
label: "Select Channel",
|
||||||
|
value: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (switchChannelNames.length > 0) {
|
||||||
|
options = options.concat(switchChannelNames.map(x => ({ label: x.name, value: x.id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridCard>
|
||||||
|
<div className="space-y-4 p-4 py-3">
|
||||||
|
<div className="grid h-full grid-rows-headerBody">
|
||||||
|
<div className="h-full space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsPageHeader
|
||||||
|
title="Select Channel"
|
||||||
|
description="Select channel on the remote KVM"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="animate-fadeIn space-y-2 opacity-0"
|
||||||
|
style={{
|
||||||
|
animationDuration: "0.7s",
|
||||||
|
animationDelay: "0.1s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<SelectMenuBasic
|
||||||
|
value={remoteKvmSelectedChannel ? remoteKvmSelectedChannel.id : ""}
|
||||||
|
size="MD"
|
||||||
|
onChange={e => onChannelSelectedById(e.target.value)}
|
||||||
|
options={options}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
);
|
||||||
|
}
|
|
@ -34,6 +34,11 @@ interface UserState {
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteKVMSwitchSelectedChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
sidebarView: AvailableSidebarViews | null;
|
sidebarView: AvailableSidebarViews | null;
|
||||||
setSidebarView: (view: AvailableSidebarViews | null) => void;
|
setSidebarView: (view: AvailableSidebarViews | null) => void;
|
||||||
|
@ -51,6 +56,14 @@ interface UIState {
|
||||||
|
|
||||||
terminalType: AvailableTerminalTypes;
|
terminalType: AvailableTerminalTypes;
|
||||||
setTerminalType: (enabled: UIState["terminalType"]) => void;
|
setTerminalType: (enabled: UIState["terminalType"]) => void;
|
||||||
|
|
||||||
|
remoteKvmEnabled: boolean;
|
||||||
|
setRemoteKvmEnabled: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
remoteKvmSelectedChannel: RemoteKVMSwitchSelectedChannel | null;
|
||||||
|
setRemoteKvmSelectedChannel: (
|
||||||
|
channel: RemoteKVMSwitchSelectedChannel | null,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUiStore = create<UIState>(set => ({
|
export const useUiStore = create<UIState>(set => ({
|
||||||
|
@ -78,6 +91,12 @@ export const useUiStore = create<UIState>(set => ({
|
||||||
isAttachedVirtualKeyboardVisible: true,
|
isAttachedVirtualKeyboardVisible: true,
|
||||||
setAttachedVirtualKeyboardVisibility: enabled =>
|
setAttachedVirtualKeyboardVisibility: enabled =>
|
||||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||||
|
|
||||||
|
remoteKvmEnabled: false,
|
||||||
|
setRemoteKvmEnabled: enabled => set({ remoteKvmEnabled: enabled }),
|
||||||
|
|
||||||
|
remoteKvmSelectedChannel: null,
|
||||||
|
setRemoteKvmSelectedChannel: channel => set({ remoteKvmSelectedChannel: channel }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface RTCState {
|
interface RTCState {
|
||||||
|
|
|
@ -33,6 +33,7 @@ import OtherSessionRoute from "./routes/devices.$id.other-session";
|
||||||
import MountRoute from "./routes/devices.$id.mount";
|
import MountRoute from "./routes/devices.$id.mount";
|
||||||
import * as SettingsRoute from "./routes/devices.$id.settings";
|
import * as SettingsRoute from "./routes/devices.$id.settings";
|
||||||
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
|
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
|
||||||
|
import SettingsSwitchRoute from "./routes/devices.$id.settings.switch";
|
||||||
import api from "./api";
|
import api from "./api";
|
||||||
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
|
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
|
||||||
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
|
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
|
||||||
|
@ -145,6 +146,10 @@ if (isOnDevice) {
|
||||||
path: "mouse",
|
path: "mouse",
|
||||||
element: <SettingsKeyboardMouseRoute />,
|
element: <SettingsKeyboardMouseRoute />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "switch",
|
||||||
|
element: <SettingsSwitchRoute />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "advanced",
|
path: "advanced",
|
||||||
element: <SettingsAdvancedRoute />,
|
element: <SettingsAdvancedRoute />,
|
||||||
|
@ -253,6 +258,10 @@ if (isOnDevice) {
|
||||||
path: "mouse",
|
path: "mouse",
|
||||||
element: <SettingsKeyboardMouseRoute />,
|
element: <SettingsKeyboardMouseRoute />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "switch",
|
||||||
|
element: <SettingsSwitchRoute />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "advanced",
|
path: "advanced",
|
||||||
element: <SettingsAdvancedRoute />,
|
element: <SettingsAdvancedRoute />,
|
||||||
|
|
|
@ -0,0 +1,529 @@
|
||||||
|
import { useState, useEffect, Fragment, useCallback } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import TextArea from "@/components/TextArea";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { MdOutlineDelete, MdOutlineNorth, MdOutlinePlusOne, MdOutlineSouth } from "react-icons/md";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
|
||||||
|
import notifications from "../notifications";
|
||||||
|
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||||
|
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
import { Checkbox } from "@/components/Checkbox";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import InputField, { FieldError, InputFieldWithLabel } from "@components/InputField";
|
||||||
|
import FieldLabel from "@components/FieldLabel";
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch channel command definition (what should be sent if channel is selected)
|
||||||
|
*/
|
||||||
|
export interface SwitchChannelCommands {
|
||||||
|
// Remote address
|
||||||
|
address: string;
|
||||||
|
// Protocol to send data in
|
||||||
|
protocol: "tcp" | "udp" | "http" | "https";
|
||||||
|
// Command format
|
||||||
|
format: "hex" | "base64" | "ascii" | "http-raw";
|
||||||
|
// Comma separated commands
|
||||||
|
commands: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch channel definition
|
||||||
|
*/
|
||||||
|
export interface SwitchChannel {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
commands: SwitchChannelCommands[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandsTextHelp: Record<SwitchChannelCommands["format"], string> = {
|
||||||
|
hex: "Provide comma separated list of commands, e.g. 0x24,0x68,0xA40A",
|
||||||
|
base64: "Provide comma separated list of commands in base64, e.g. aGVsbG8=,d29ybGQ=",
|
||||||
|
ascii: "Provide newline separated list of commands, e.g. hello\nworld",
|
||||||
|
"http-raw": "Provide raw HTTP request, e.g. GET / HTTP/1.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GetCompatibleCommands = (protocol: SwitchChannelCommands["protocol"]) => {
|
||||||
|
if (protocol == "http" || protocol == "https") {
|
||||||
|
return ["http-raw"];
|
||||||
|
} else {
|
||||||
|
return ["hex", "base64", "ascii"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandsTextPlaceholder: Record<SwitchChannelCommands["format"], string> = {
|
||||||
|
hex: "0x24,0x68,0xA4",
|
||||||
|
base64: "aGVsbG8=,d29ybGQ=",
|
||||||
|
ascii: "hello\nworld",
|
||||||
|
"http-raw": `GET /images HTTP/1.1
|
||||||
|
Host: example.com
|
||||||
|
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36
|
||||||
|
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
|
||||||
|
Accept-Encoding: gzip, deflate
|
||||||
|
Connection: close`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenerateSwitchChannelId = () => {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsSwitchRoute() {
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
const [kvmSwitchEnabled, setKvmSwitchEnabled] = useState<boolean | null>(null);
|
||||||
|
const [switchChannels, setSwitchChannels] = useState<SwitchChannel[]>([]);
|
||||||
|
|
||||||
|
const updateSwitchChannelData = (index: number, data: SwitchChannel) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
newSwitchChannels[index] = data;
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSwitchChannelCommands = (index: number, channelIndex: number, data: SwitchChannelCommands) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
newSwitchChannels[index].commands[channelIndex] = data;
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeChannelById = (index: number) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
newSwitchChannels.splice(index, 1);
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCommandById = (index: number, channelIndex: number) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
newSwitchChannels[index].commands.splice(channelIndex, 1);
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChannel = (afterIndex: number) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
newSwitchChannels.splice(afterIndex + 1, 0, {
|
||||||
|
name: "",
|
||||||
|
id: GenerateSwitchChannelId(),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
address: "",
|
||||||
|
protocol: "tcp",
|
||||||
|
format: "hex",
|
||||||
|
commands: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCommand = (channelIndex: number, afterIndex: number) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
newSwitchChannels[channelIndex].commands.splice(afterIndex + 1, 0, {
|
||||||
|
address: "",
|
||||||
|
protocol: "tcp",
|
||||||
|
format: "hex",
|
||||||
|
commands: "",
|
||||||
|
});
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveChannel = (fromIndex: number, moveUp: boolean) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
const channel = newSwitchChannels[fromIndex];
|
||||||
|
newSwitchChannels.splice(fromIndex, 1);
|
||||||
|
newSwitchChannels.splice(fromIndex + (moveUp ? -1 : 1), 0, channel);
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveCommand = (channelIndex: number, fromIndex: number, moveUp: boolean) => {
|
||||||
|
const newSwitchChannels = [...switchChannels];
|
||||||
|
const command = newSwitchChannels[channelIndex].commands[fromIndex];
|
||||||
|
newSwitchChannels[channelIndex].commands.splice(fromIndex, 1);
|
||||||
|
newSwitchChannels[channelIndex].commands.splice(fromIndex + (moveUp ? -1 : 1), 0, command);
|
||||||
|
setSwitchChannels(newSwitchChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getKvmSwitchEnabled", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to get KVM switch state: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const enabled = Boolean(resp.result);
|
||||||
|
setKvmSwitchEnabled(enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
send("getKvmSwitchChannels", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(`Failed to get switch channels: ${resp.error.data || "Unknown error"}`);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSwitchChannels(resp.result as SwitchChannel[]);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSwitchChannels([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const setKvmSwitchEnabledHandler = (enabled: boolean) => {
|
||||||
|
send("setKvmSwitchEnabled", { enabled: Boolean(enabled) }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to set KVM switch state: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
notifications.success(`Enabled KVM Switch integration`);
|
||||||
|
} else {
|
||||||
|
notifications.success(`Disabled KVM Switch integration`);
|
||||||
|
}
|
||||||
|
setKvmSwitchEnabled(enabled);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorsForChannelCommand = useCallback((index: number, channelIndex: number) => {
|
||||||
|
const channel = switchChannels[index];
|
||||||
|
const command = channel.commands[channelIndex];
|
||||||
|
|
||||||
|
let errors: string[] = [];
|
||||||
|
|
||||||
|
// Check name
|
||||||
|
const nameValue = channel.name.trim();
|
||||||
|
if (nameValue.length === 0) {
|
||||||
|
errors.push("Name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check address
|
||||||
|
if (command.protocol !== "http" && command.protocol !== "https") {
|
||||||
|
const addressValue = command.address.trim();
|
||||||
|
// Check that it is in form host:port
|
||||||
|
const addressParts = addressValue.split(":");
|
||||||
|
if (addressValue.length === 0) {
|
||||||
|
errors.push("Address cannot be empty");
|
||||||
|
} else if (addressParts.length !== 2) {
|
||||||
|
errors.push("Address must be in form host:port");
|
||||||
|
} else if (addressParts[0].length === 0) {
|
||||||
|
errors.push("Address host cannot be empty");
|
||||||
|
} else if (addressParts[1].length === 0) {
|
||||||
|
errors.push("Address port cannot be empty");
|
||||||
|
} else if (!/^\d+$/.test(addressParts[1])) {
|
||||||
|
errors.push("Address port must be a number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that commands map to the format
|
||||||
|
if (command.format === "hex") {
|
||||||
|
const messages = command.commands.split(",").map(x => x.trim());
|
||||||
|
if (messages.length === 0) {
|
||||||
|
errors.push("Commands cannot be empty");
|
||||||
|
} else if (messages.some(x => !/^\s*0x[0-9a-fA-F]+\s*$/.test(x))) {
|
||||||
|
errors.push("Commands must be in hex format (0x123020392193)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.format === "base64") {
|
||||||
|
const messages = command.commands.split(",").map(x => x.trim());
|
||||||
|
if (messages.length === 0) {
|
||||||
|
errors.push("Commands cannot be empty");
|
||||||
|
}
|
||||||
|
let anyBase64Failed = false;
|
||||||
|
if ("atob" in window) {
|
||||||
|
for (const message of messages) {
|
||||||
|
try {
|
||||||
|
atob(message);
|
||||||
|
} catch (e) {
|
||||||
|
anyBase64Failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyBase64Failed) {
|
||||||
|
errors.push("Commands must be in base64 format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.format === "http-raw") {
|
||||||
|
// Check that main components of HTTP request are in the command field
|
||||||
|
const commandValue = command.commands.trim();
|
||||||
|
if (commandValue.length === 0) {
|
||||||
|
errors.push("HTTP request cannot be empty");
|
||||||
|
} else {
|
||||||
|
// Check that Host header is present
|
||||||
|
if (!commandValue.includes("Host:")) {
|
||||||
|
errors.push("HTTP request must contain Host header");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}, [switchChannels]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!kvmSwitchEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over getErrorsForChannelCommand
|
||||||
|
var anyErrorsFound = false;
|
||||||
|
for (const index in switchChannels) {
|
||||||
|
if (anyErrorsFound) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (const channelIndex in switchChannels[index].commands) {
|
||||||
|
const errors = getErrorsForChannelCommand(Number(index), Number(channelIndex));
|
||||||
|
if (errors.length > 0) {
|
||||||
|
anyErrorsFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyErrorsFound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
config: {
|
||||||
|
channels: switchChannels
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
send("setKvmSwitchChannels", payload, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to set switch channels: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [kvmSwitchEnabled, switchChannels, getErrorsForChannelCommand]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsPageHeader
|
||||||
|
title="KVM Switch"
|
||||||
|
description="Configure remote KVM switch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title="Enable remote KVM Switch"
|
||||||
|
description="Enable ability to switch devices using external KVM Switch"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={kvmSwitchEnabled ?? false}
|
||||||
|
disabled={kvmSwitchEnabled === null}
|
||||||
|
onChange={e => setKvmSwitchEnabledHandler(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{!!kvmSwitchEnabled && switchChannels.length > 0 && (
|
||||||
|
<div className="space-y-4 flex flex-col">
|
||||||
|
{switchChannels.map((option, index) => (
|
||||||
|
<GridCard key={index}>
|
||||||
|
<div className="space-y-4 p-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel label={"Channel #" + (index + 1)} />
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{switchChannels.length > 1 && (
|
||||||
|
<Fragment>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlineNorth}
|
||||||
|
onClick={() => {
|
||||||
|
moveChannel(index, true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlineSouth}
|
||||||
|
onClick={() => {
|
||||||
|
moveChannel(index, false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlinePlusOne}
|
||||||
|
onClick={() => {
|
||||||
|
addChannel(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="danger"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlineDelete}
|
||||||
|
onClick={() => {
|
||||||
|
// Remove channel by index
|
||||||
|
removeChannelById(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputFieldWithLabel
|
||||||
|
label="Channel Name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Channel name for UI"
|
||||||
|
value={option.name}
|
||||||
|
onChange={e => updateSwitchChannelData(index, { ...option, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
{option.commands.map((command, commandIndex) => (
|
||||||
|
<div key={commandIndex} className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FieldLabel label={"Command #" + (commandIndex + 1)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{option.commands.length > 1 && (
|
||||||
|
<Fragment>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlineNorth}
|
||||||
|
onClick={() => {
|
||||||
|
moveCommand(index, commandIndex, true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlineSouth}
|
||||||
|
onClick={() => {
|
||||||
|
moveCommand(index, commandIndex, false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlinePlusOne}
|
||||||
|
onClick={() => {
|
||||||
|
addCommand(index, commandIndex);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{option.commands.length > 1 && (
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="danger"
|
||||||
|
text=""
|
||||||
|
LeadingIcon={MdOutlineDelete}
|
||||||
|
onClick={() => {
|
||||||
|
removeCommandById(index, commandIndex);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<FieldLabel label="Address" id={`address-${index}-${commandIndex}`} as="span" />
|
||||||
|
<InputField
|
||||||
|
id={`address-${index}-${commandIndex}`}
|
||||||
|
type="text"
|
||||||
|
placeholder={command.protocol == "http" || command.protocol == "https" ? "DISABLED FOR HTTP(s)" : "127.0.0.1"}
|
||||||
|
disabled={command.protocol == "http" || command.protocol == "https"}
|
||||||
|
value={command.protocol == "http" || command.protocol == "https" ? "" : command.address}
|
||||||
|
onChange={e => updateSwitchChannelCommands(index, commandIndex, { ...command, address: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Protocol"
|
||||||
|
value={command.protocol}
|
||||||
|
fullWidth
|
||||||
|
onChange={e => updateSwitchChannelCommands(
|
||||||
|
index,
|
||||||
|
commandIndex,
|
||||||
|
{
|
||||||
|
...command,
|
||||||
|
protocol: e.target.value as SwitchChannelCommands["protocol"],
|
||||||
|
format: GetCompatibleCommands(e.target.value as SwitchChannelCommands["protocol"])[0] as SwitchChannelCommands["format"],
|
||||||
|
commands: ""
|
||||||
|
})}
|
||||||
|
options={[
|
||||||
|
{ label: "TCP", value: "tcp" },
|
||||||
|
{ label: "UDP", value: "udp" },
|
||||||
|
{ label: "HTTP", value: "http" },
|
||||||
|
{ label: "HTTPS", value: "https" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SelectMenuBasic
|
||||||
|
label="Format"
|
||||||
|
value={command.format}
|
||||||
|
fullWidth
|
||||||
|
onChange={e => updateSwitchChannelCommands(
|
||||||
|
index,
|
||||||
|
commandIndex,
|
||||||
|
{
|
||||||
|
...command,
|
||||||
|
format: e.target.value as SwitchChannelCommands["format"], commands: ""
|
||||||
|
})}
|
||||||
|
options={[
|
||||||
|
{ label: "HEX", value: "hex" },
|
||||||
|
{ label: "Base64", value: "base64" },
|
||||||
|
{ label: "ASCII", value: "ascii" },
|
||||||
|
{ label: "HTTP Raw", value: "http-raw" },
|
||||||
|
].filter(f => GetCompatibleCommands(command.protocol).includes(f.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<FieldLabel label="Command" id={`command-${index}-${commandIndex}`} as="span" description={CommandsTextHelp[command.format]} />
|
||||||
|
<TextArea
|
||||||
|
id={`command-${index}-${commandIndex}`}
|
||||||
|
placeholder={CommandsTextPlaceholder[command.format]}
|
||||||
|
value={command.commands}
|
||||||
|
onChange={e => updateSwitchChannelCommands(index, commandIndex, { ...command, commands: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{getErrorsForChannelCommand(index, commandIndex).length > 0 && (
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<FieldError error={getErrorsForChannelCommand(index, commandIndex).join(", ")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(commandIndex < option.commands.length - 1) && (<div className="h-px bg-gray-200 dark:bg-gray-700" />)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</GridCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!kvmSwitchEnabled && switchChannels.length == 0 && (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<Button
|
||||||
|
size="LG"
|
||||||
|
theme="primary"
|
||||||
|
text="Add Channel"
|
||||||
|
onClick={() => {
|
||||||
|
addChannel(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import {
|
||||||
LuWrench,
|
LuWrench,
|
||||||
LuArrowLeft,
|
LuArrowLeft,
|
||||||
LuPalette,
|
LuPalette,
|
||||||
|
LuMerge,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
@ -195,6 +196,17 @@ export default function SettingsRoute() {
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<NavLink
|
||||||
|
to="switch"
|
||||||
|
className={({ isActive }) => (isActive ? "active" : "")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||||
|
<LuMerge className="h-4 w-4 shrink-0" />
|
||||||
|
<h1>KVM Switch</h1>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="advanced"
|
to="advanced"
|
||||||
|
|
Loading…
Reference in New Issue