mirror of https://github.com/jetkvm/kvm.git
Merge 41e2bd7323
into 3fbcb7e5c4
This commit is contained in:
commit
5b4280eb50
|
@ -34,6 +34,10 @@ type Config struct {
|
|||
TLSMode string `json:"tls_mode"`
|
||||
UsbConfig *usbgadget.Config `json:"usb_config"`
|
||||
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"
|
||||
|
|
66
jsonrpc.go
66
jsonrpc.go
|
@ -737,6 +737,65 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
|||
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 {
|
||||
if err := gadget.UpdateGadgetConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write gadget config: %w", err)
|
||||
|
@ -862,4 +921,11 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
|
||||
"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,264 @@
|
|||
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, ",") {
|
||||
// Trim spaces, remove 0x prefix and parse as HEX
|
||||
commandText := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(cmd), "0x"))
|
||||
b, err := hex.DecodeString(commandText)
|
||||
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
|
||||
}
|
|
@ -35,6 +35,7 @@
|
|||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "^13.12.0",
|
||||
"xterm": "^5.3.0",
|
||||
"zustand": "^4.5.2"
|
||||
|
@ -6217,6 +6218,18 @@
|
|||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"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": {
|
||||
"version": "13.12.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "^13.12.0",
|
||||
"xterm": "^5.3.0",
|
||||
"zustand": "^4.5.2"
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
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 { 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 { Button } from "@components/Button";
|
||||
|
@ -18,13 +18,16 @@ import PasteModal from "@/components/popovers/PasteModal";
|
|||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||
import MountPopopover from "@/components/popovers/MountPopover";
|
||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||
import SelectChannelPopover from "@/components/popovers/SelectChannelPopover";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
}: {
|
||||
requestFullscreen: () => Promise<void>;
|
||||
}) {
|
||||
const [send] = useJsonRpc();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
|
||||
|
@ -33,6 +36,10 @@ export default function Actionbar({
|
|||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const terminalType = useUiStore(state => state.terminalType);
|
||||
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(
|
||||
state => state.remoteVirtualMediaState,
|
||||
);
|
||||
|
@ -56,6 +63,28 @@ export default function Actionbar({
|
|||
[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 (
|
||||
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<div
|
||||
|
@ -207,6 +236,44 @@ export default function Actionbar({
|
|||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||
/>
|
||||
</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 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;
|
||||
}
|
||||
|
||||
export interface RemoteKVMSwitchSelectedChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
sidebarView: AvailableSidebarViews | null;
|
||||
setSidebarView: (view: AvailableSidebarViews | null) => void;
|
||||
|
@ -51,6 +56,14 @@ interface UIState {
|
|||
|
||||
terminalType: AvailableTerminalTypes;
|
||||
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 => ({
|
||||
|
@ -78,6 +91,12 @@ export const useUiStore = create<UIState>(set => ({
|
|||
isAttachedVirtualKeyboardVisible: true,
|
||||
setAttachedVirtualKeyboardVisibility: enabled =>
|
||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||
|
||||
remoteKvmEnabled: false,
|
||||
setRemoteKvmEnabled: enabled => set({ remoteKvmEnabled: enabled }),
|
||||
|
||||
remoteKvmSelectedChannel: null,
|
||||
setRemoteKvmSelectedChannel: channel => set({ remoteKvmSelectedChannel: channel }),
|
||||
}));
|
||||
|
||||
interface RTCState {
|
||||
|
|
|
@ -33,6 +33,7 @@ import OtherSessionRoute from "./routes/devices.$id.other-session";
|
|||
import MountRoute from "./routes/devices.$id.mount";
|
||||
import * as SettingsRoute from "./routes/devices.$id.settings";
|
||||
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
|
||||
import SettingsSwitchRoute from "./routes/devices.$id.settings.switch";
|
||||
import api from "./api";
|
||||
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
|
||||
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
|
||||
|
@ -145,6 +146,10 @@ if (isOnDevice) {
|
|||
path: "mouse",
|
||||
element: <SettingsKeyboardMouseRoute />,
|
||||
},
|
||||
{
|
||||
path: "switch",
|
||||
element: <SettingsSwitchRoute />,
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
element: <SettingsAdvancedRoute />,
|
||||
|
@ -253,6 +258,10 @@ if (isOnDevice) {
|
|||
path: "mouse",
|
||||
element: <SettingsKeyboardMouseRoute />,
|
||||
},
|
||||
{
|
||||
path: "switch",
|
||||
element: <SettingsSwitchRoute />,
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
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,
|
||||
LuArrowLeft,
|
||||
LuPalette,
|
||||
LuMerge,
|
||||
} from "react-icons/lu";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
|
@ -195,6 +196,17 @@ export default function SettingsRoute() {
|
|||
</div>
|
||||
</NavLink>
|
||||
</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">
|
||||
<NavLink
|
||||
to="advanced"
|
||||
|
|
Loading…
Reference in New Issue