Compare commits

...

6 Commits

Author SHA1 Message Date
Marc Brooks a4f02c2564
Merge 17b364b58a into 488276f3a8 2025-07-10 00:03:49 +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
Marc Brooks 17b364b58a
feat(ui) Add deviceName or deviceID to the browser tab title
Addresses #304. In local mode will show the deviceId, in cloud connection will show the deviceName.

Would be nice to show the hostname, but that would require device-side code changes to the /device endpoint.
2025-06-17 11:19:20 -05:00
12 changed files with 186 additions and 70 deletions

View File

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

View File

@ -685,6 +685,7 @@ type DCPowerState struct {
Voltage float64 `json:"voltage"` Voltage float64 `json:"voltage"`
Current float64 `json:"current"` Current float64 `json:"current"`
Power float64 `json:"power"` Power float64 `json:"power"`
RestoreState int `json:"restoreState"`
} }
func rpcGetDCPowerState() (DCPowerState, error) { func rpcGetDCPowerState() (DCPowerState, error) {
@ -700,6 +701,15 @@ func rpcSetDCPowerState(enabled bool) error {
return nil 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) { func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil return config.ActiveExtension, nil
} }
@ -1088,6 +1098,7 @@ var rpcHandlers = map[string]RPCHandler{
"getBacklightSettings": {Func: rpcGetBacklightSettings}, "getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState}, "getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension}, "getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState}, "getATXState": {Func: rpcGetATXState},

View File

@ -142,6 +142,7 @@ var dcState DCPowerState
func runDCControl() { func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger() scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port) reader := bufio.NewReader(port)
hasRestoreFeature := false
for { for {
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
@ -151,7 +152,13 @@ func runDCControl() {
// Split the line by semicolon // Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";") 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") scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue continue
} }
@ -163,6 +170,17 @@ func runDCControl() {
continue continue
} }
dcState.IsOn = powerState == 1 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) milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil { if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage") scopedLogger.Warn().Err(err).Msg("Invalid voltage")
@ -210,6 +228,25 @@ func setDCPowerState(on bool) error {
return nil 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{ var defaultMode = &serial.Mode{
BaudRate: 115200, BaudRate: 115200,
DataBits: 8, DataBits: 8,

View File

@ -262,23 +262,6 @@ export default function Actionbar({
}} }}
/> />
</div> </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> <div>
<Button <Button
size="XS" size="XS"

View File

@ -8,12 +8,14 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel"; import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import {SelectMenuBasic} from "@components/SelectMenuBasic";
interface DCPowerState { interface DCPowerState {
isOn: boolean; isOn: boolean;
voltage: number; voltage: number;
current: number; current: number;
power: number; power: number;
restoreState: number;
} }
export function DCPowerControl() { export function DCPowerControl() {
@ -43,6 +45,20 @@ export function DCPowerControl() {
getDCPowerState(); // Refresh state after change 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(() => { useEffect(() => {
getDCPowerState(); getDCPowerState();
@ -63,7 +79,7 @@ export function DCPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[160px] animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
{/* Power Controls */} {/* Power Controls */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -84,6 +100,21 @@ export function DCPowerControl() {
onClick={() => handlePowerToggle(false)} onClick={() => handlePowerToggle(false)}
/> />
</div> </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" /> <hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Display */} {/* Status Display */}

View File

@ -308,9 +308,6 @@ interface SettingsState {
keyboardLayout: string; keyboardLayout: string;
setKeyboardLayout: (layout: string) => void; setKeyboardLayout: (layout: string) => void;
actionBarCtrlAltDel: boolean;
setActionBarCtrlAltDel: (enabled: boolean) => void;
keyboardLedSync: KeyboardLedSync; keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void; setKeyboardLedSync: (sync: KeyboardLedSync) => void;
@ -359,9 +356,6 @@ export const useSettingsStore = create(
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }), setKeyboardLayout: layout => set({ keyboardLayout: layout }),
actionBarCtrlAltDel: false,
setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }),
keyboardLedSync: "auto", keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }), setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),

View File

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

View File

@ -58,9 +58,11 @@ import { SystemVersionInfo } from "./devices.$id.settings.general.update";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
deviceId: string;
} }
interface CloudLoaderResp { interface CloudLoaderResp {
deviceId: string;
deviceName: string; deviceName: string;
user: User | null; user: User | null;
iceConfig: { iceConfig: {
@ -85,7 +87,7 @@ const deviceLoader = async () => {
if (deviceRes.status === 401) return redirect("/login-local"); if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) { if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice; const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode }; return { authMode: device.authMode, deviceId: device.deviceId };
} }
throw new Error("Error fetching device"); throw new Error("Error fetching device");
@ -111,7 +113,7 @@ const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> =>
device: { id: string; name: string; user: { googleId: string } }; device: { id: string; name: string; user: { googleId: string } };
}; };
return { user, iceConfig, deviceName: device.name || device.id }; return { user, iceConfig, deviceName: device.name || device.id, deviceId: device.id };
}; };
const loader = async ({ params }: LoaderFunctionArgs) => { const loader = async ({ params }: LoaderFunctionArgs) => {
@ -123,6 +125,7 @@ export default function KvmIdRoute() {
// Depending on the mode, we set the appropriate variables // Depending on the mode, we set the appropriate variables
const user = "user" in loaderResp ? loaderResp.user : null; const user = "user" in loaderResp ? loaderResp.user : null;
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null; const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
const deviceId = "deviceId" in loaderResp ? loaderResp.deviceId : null;
const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null; const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null;
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
@ -788,6 +791,14 @@ export default function KvmIdRoute() {
setupPeerConnection, setupPeerConnection,
]); ]);
// update the browser tab title with the name of the JetKVM we're discussing
useEffect(() => {
const name = deviceName || deviceId;
document.title = (name && name.length > 0)
? "JetKVM-" + name
: "JetKVM";
}, [deviceName, deviceId]);
return ( return (
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && ( {!outlet && otaState.updating && (