Compare commits

...

6 Commits

Author SHA1 Message Date
Daniel Collins 917a28fe4a
Merge 002c2f4937 into 488276f3a8 2025-07-10 00:36:25 +02:00
adammkelly 488276f3a8
feat(ui): reboot device (#421) (#505) 2025-07-10 00:02:13 +02:00
Patrick Hofmann 7267347261
feat(dc-power-extension): power restore mode in DCPowerControl component (#672)
* DC-extension: Supporting to set the power restore mode in DCPowerControl component

* fixing lint issue
2025-07-09 23:58:46 +02:00
Marc Brooks 393bc122d4
chore: fix the base usb configuration (#610)
In reviewing the config.go settings for idProduct and bcdDevice are not formatted correctly. All examples on GitHub have 0x0104 and 0x0100 respectively. The idProduct value gets overwritten with valid values when you change the configuration (because they are correct in the options), but until you do the USB initialization will not be correct.
2025-07-09 23:57:51 +02:00
Marc Brooks 6d13e1be12
chore: remove ActionBar-Ctrl-Alt-Del (#669) 2025-07-09 23:53:44 +02:00
Daniel Collins 002c2f4937 Implement HTTP proxy option (#515).
This commit adds a "Proxy" field to the network settings screen, which
can be used to specify a HTTP proxy for any outgoing requests from the
device.
2025-05-28 20:52:51 +01:00
16 changed files with 239 additions and 72 deletions

View File

@ -3,6 +3,7 @@ package confparser
import (
"fmt"
"net"
"net/url"
"reflect"
"slices"
"strconv"
@ -372,6 +373,10 @@ func (f *FieldConfig) validateField() error {
if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
}
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
}
default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
}

View File

@ -3,6 +3,8 @@ package network
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/guregu/null/v6"
@ -32,8 +34,9 @@ type IPv6StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}
type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
@ -69,6 +72,18 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
return listenOptions
}
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
}
}
}
func (s *NetworkInterfaceState) GetHostname() string {
hostname := ToValidHostname(s.config.Hostname.String)

View File

@ -5,6 +5,7 @@ import (
"errors"
"math/rand"
"net/http"
"net/url"
"strconv"
"time"
)
@ -57,6 +58,7 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
ctx,
url,
timeout,
t.networkConfig.GetTransportProxyFunc(),
)
duration := time.Since(startTime)
@ -122,10 +124,16 @@ func queryHttpTime(
ctx context.Context,
url string,
timeout time.Duration,
proxyFunc func(*http.Request) (*url.URL, error),
) (now *time.Time, response *http.Response, err error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxyFunc
client := http.Client{
Timeout: timeout,
Transport: transport,
Timeout: timeout,
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, nil, err

View File

@ -30,8 +30,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
attrs: gadgetAttributes{
"bcdUSB": "0x0200", // USB 2.0
"idVendor": "0x1d6b", // The Linux Foundation
"idProduct": "0104", // Multifunction Composite Gadget
"bcdDevice": "0100",
"idProduct": "0x0104", // Multifunction Composite Gadget
"bcdDevice": "0x0100", // USB2
},
configAttrs: gadgetAttributes{
"MaxPower": "250", // in unit of 2mA

View File

@ -681,10 +681,11 @@ func rpcResetConfig() error {
}
type DCPowerState struct {
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
RestoreState int `json:"restoreState"`
}
func rpcGetDCPowerState() (DCPowerState, error) {
@ -700,6 +701,15 @@ func rpcSetDCPowerState(enabled bool) error {
return nil
}
func rpcSetDCRestoreState(state int) error {
logger.Info().Int("state", state).Msg("Setting DC restore state")
err := setDCRestoreState(state)
if err != nil {
return fmt.Errorf("failed to set DC restore state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
@ -1088,6 +1098,7 @@ var rpcHandlers = map[string]RPCHandler{
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState},

10
ota.go
View File

@ -89,7 +89,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
return nil, fmt.Errorf("error creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
@ -135,6 +142,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
client := http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
TLSHandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{
RootCAs: rootcerts.ServerCertPool(),

View File

@ -142,6 +142,7 @@ var dcState DCPowerState
func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port)
hasRestoreFeature := false
for {
line, err := reader.ReadString('\n')
if err != nil {
@ -151,7 +152,13 @@ func runDCControl() {
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 {
if len(parts) == 5 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension with restore feature")
hasRestoreFeature = true
} else if len(parts) == 4 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension without restore feature")
hasRestoreFeature = false
} else {
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue
}
@ -163,6 +170,17 @@ func runDCControl() {
continue
}
dcState.IsOn = powerState == 1
if hasRestoreFeature {
restoreState, err := strconv.Atoi(parts[4])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid restore state")
continue
}
dcState.RestoreState = restoreState
} else {
// -1 means not supported
dcState.RestoreState = -1
}
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
@ -210,6 +228,25 @@ func setDCPowerState(on bool) error {
return nil
}
func setDCRestoreState(state int) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "RESTORE_MODE_OFF\n"
switch state {
case 1:
command = "RESTORE_MODE_ON\n"
case 2:
command = "RESTORE_MODE_LAST_STATE\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
var defaultMode = &serial.Mode{
BaudRate: 115200,
DataBits: 8,

View File

@ -262,23 +262,6 @@ export default function Actionbar({
}}
/>
</div>
{/* {useSettingsStore().actionBarCtrlAltDel && (
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Ctrl + Alt + Del"
LeadingIcon={FaLock}
onClick={() => {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
}}
/>
</div>
)} */}
<div>
<Button
size="XS"

View File

@ -8,12 +8,14 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner";
import {SelectMenuBasic} from "@components/SelectMenuBasic";
interface DCPowerState {
isOn: boolean;
voltage: number;
current: number;
power: number;
restoreState: number;
}
export function DCPowerControl() {
@ -43,6 +45,20 @@ export function DCPowerControl() {
getDCPowerState(); // Refresh state after change
});
};
const handleRestoreChange = (state: number) => {
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
send("setDCRestoreState", { state }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
getDCPowerState(); // Refresh state after change
});
};
useEffect(() => {
getDCPowerState();
@ -63,7 +79,7 @@ export function DCPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[160px] animate-fadeIn opacity-0">
<Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Power Controls */}
<div className="flex items-center space-x-2">
@ -84,6 +100,21 @@ export function DCPowerControl() {
onClick={() => handlePowerToggle(false)}
/>
</div>
{powerState.restoreState > -1 ? (
<div className="flex items-center">
<SelectMenuBasic
size="SM"
label="Restore Power Loss"
value={powerState.restoreState}
onChange={e => handleRestoreChange(parseInt(e.target.value))}
options={[
{ value: '0', label: "Power OFF" },
{ value: '1', label: "Power ON" },
{ value: '2', label: "Last State" },
]}
/>
</div>
) : null}
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Display */}

View File

@ -308,9 +308,6 @@ interface SettingsState {
keyboardLayout: string;
setKeyboardLayout: (layout: string) => void;
actionBarCtrlAltDel: boolean;
setActionBarCtrlAltDel: (enabled: boolean) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
@ -359,9 +356,6 @@ export const useSettingsStore = create(
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
actionBarCtrlAltDel: false,
setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
@ -753,6 +747,7 @@ export type TimeSyncMode =
export interface NetworkSettings {
hostname: string;
domain: string;
http_proxy: string;
ipv4_mode: IPv4Mode;
ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode;

View File

@ -42,6 +42,7 @@ import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralRebootRoute from "./routes/devices.$id.settings.general.reboot";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SettingsNetworkRoute from "./routes/devices.$id.settings.network";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
@ -140,6 +141,10 @@ if (isOnDevice) {
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "reboot",
element: <SettingsGeneralRebootRoute />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,

View File

@ -1,28 +0,0 @@
import { Checkbox } from "@/components/Checkbox";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { useSettingsStore } from "@/hooks/stores";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsCtrlAltDelRoute() {
const enableCtrlAltDel = useSettingsStore(state => state.actionBarCtrlAltDel);
const setEnableCtrlAltDel = useSettingsStore(state => state.setActionBarCtrlAltDel);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Action Bar"
description="Customize the action bar of your JetKVM interface"
/>
<div className="space-y-4">
<SettingsItem title="Enable Ctrl-Alt-Del" description="Enable the Ctrl-Alt-Del key on the virtual keyboard">
<Checkbox
checked={enableCtrlAltDel}
onChange={e => setEnableCtrlAltDel(e.target.checked)}
/>
</SettingsItem>
</div>
</div>
);
}

View File

@ -92,6 +92,21 @@ export default function SettingsGeneralRoute() {
/>
</SettingsItem>
</div>
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Reboot Device"
description="Power cycle the JetKVM"
/>
<div>
<Button
size="SM"
theme="light"
text="Reboot Device"
onClick={() => navigateTo("./reboot")}
/>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,66 @@
import { useNavigate } from "react-router-dom";
import { useCallback } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
// This is where we send the RPC to the golang binary
send("reboot", {force: true});
}, [send]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({
onClose,
onConfirmUpdate,
}: {
onClose: () => void;
onConfirmUpdate: () => void;
}) {
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
<ConfirmationBox
onYes={onConfirmUpdate}
onNo={onClose}
/>
</div>
</div>
);
}
function ConfirmationBox({
onYes,
onNo,
}: {
onYes: () => void;
onNo: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Reboot JetKVM
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Do you want to proceed with rebooting the system?
</p>
<div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Yes" onClick={onYes} />
<Button size="SM" theme="blank" text="No" onClick={onNo} />
</div>
</div>
</div>
);
}

View File

@ -116,15 +116,6 @@ export default function SettingsHardwareRoute() {
}}
/>
</SettingsItem>
{/* <SettingsItem
title="Enable Ctrl+Alt+Del Action Bar"
description="Enable or disable the action bar action for sending a Ctrl+Alt+Del to the host"
>
<Checkbox
checked={actionBarConfig.ctrlAltDel}
onChange={onActionBarItemChange("ctrlAltDel")}
/>
</SettingsItem> */}
{settings.backlightSettings.max_brightness != 0 && (
<>
<SettingsItem

View File

@ -34,6 +34,7 @@ dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = {
hostname: "",
http_proxy: "",
domain: "",
ipv4_mode: "unknown",
ipv6_mode: "unknown",
@ -185,6 +186,10 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, hostname: value });
};
const handleProxyChange = (value: string) => {
setNetworkSettings({ ...networkSettings, http_proxy: value });
};
const handleDomainChange = (value: string) => {
setNetworkSettings({ ...networkSettings, domain: value });
};
@ -253,6 +258,26 @@ export default function SettingsNetworkRoute() {
</div>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="HTTP Proxy"
description="Proxy server for outgoing HTTP(S) requests from the device. Blank for none."
>
<div className="relative">
<div>
<InputField
size="SM"
type="text"
placeholder="http://proxy.example.com:8080/"
defaultValue={networkSettings.http_proxy}
onChange={e => {
handleProxyChange(e.target.value);
}}
/>
</div>
</div>
</SettingsItem>
</div>
<div className="space-y-4">
<div className="space-y-1">