Compare commits

...

6 Commits

Author SHA1 Message Date
Marc Brooks b605a17b7d
Merge 818f9078e3 into 584768bacf 2025-07-10 12:23:30 +02:00
Aveline 584768bacf
chore: remove /device/ui-config.js endpoint (#678) 2025-07-10 12:04:47 +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 818f9078e3
Bump packages
Move to current on all non-major upgrades
Fixes the tainted hardware WebGL video renderer if video settings are at default (1.0) values

## Runtime

|  Package | From  | To  |
|---|---|---|
| @headlessui/react | 2.2.3 | 2.2.4 |
| @vitejs/plugin-basic-ssl | 2.0.0 | 2.1.0 |
| cva | 1.0.0-beta.3 | 1.0.0-beta.4 |
| focus-trap-react | 11.0.3 | 11.0.4 |
| framer-motion | 12.11.5 | 12.23.0 |
| react-simple-keyboard | 3.8.72 | 3.8.89 |
| tailwind-merge | 3.3.0 | 3.3.1 |
| validator | 13.15.0 | 13.15.15 |

## Dev

|  Package | From  | To  |
|---|---|---|
| @eslint/compat | 1.2.9 | 1.3.1 |
| @eslint/js | 9.26.0 | 9.30.1 |
| @tailwindcss/postcss | 4.1.7 | 4.1.11 |
| @tailwindcss/vite | 4.1.8 | 4.1.10 |
| @types/react | 19.1.4 | 19.1.8  |
| @types/react-dom | 19.1.5 | 19.1.6 |
| @types/validator | 13.15.0 | 13.15.2 |
| @typescript-eslint/eslint-plugin | 8.32.1 | 8.34.0 |
| @typescript-eslint/parser | 8.32.1 | 8.35.1  |
| @vitejs/plugin-react-swc | 3.9.0 | 3.10.2 |
| eslint | 9.26.0 | 9.30.1 |
| globals | 16.1.0 | 16.3.0 |
| postcss  | 8.5.3 | 8.5.6 |
| prettier | 3.5.3 | 3.6.2 |
| prettier-plugin-tailwindcss | 0.6.11 | 0.6.13 |
| tailwindcss | 4.1.7 | 4.1.11 |
2025-07-03 15:07:29 -05:00
11 changed files with 942 additions and 710 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

@ -681,10 +681,11 @@ func rpcResetConfig() error {
} }
type DCPowerState struct { type DCPowerState struct {
IsOn bool `json:"isOn"` IsOn bool `json:"isOn"`
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,

1385
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,21 +19,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.3", "@headlessui/react": "^2.2.4",
"@headlessui/tailwindcss": "^0.2.2", "@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-basic-ssl": "^2.1.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.3", "cva": "^1.0.0-beta.4",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.3", "focus-trap-react": "^11.0.4",
"framer-motion": "^12.11.4", "framer-motion": "^12.23.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"react": "^19.1.0", "react": "^19.1.0",
@ -42,42 +42,42 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.72", "react-simple-keyboard": "^3.8.89",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.0", "validator": "^13.15.15",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^1.3.1",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0", "@eslint/js": "^9.30.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.4", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.0", "@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.35.1",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.26.0", "eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0", "globals": "^16.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"prettier": "^3.5.3", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.13",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.11",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"

View File

@ -657,6 +657,16 @@ export default function WebRTCVideo() {
return true; return true;
}, [isPlaying, isPointerLockActive, isPointerLockPossible, isVideoLoading, settings.mouseMode, videoHeight, videoWidth]); }, [isPlaying, isPointerLockActive, isPointerLockPossible, isVideoLoading, settings.mouseMode, videoHeight, videoWidth]);
// Conditionally set the filter style so we don't fallback to software rendering if these values are default of 1.0
const videoStyle = useMemo(() => {
const isDefault = videoSaturation === 1.0 && videoBrightness === 1.0 && videoContrast === 1.0;
return isDefault
? {} // No filter if all settings are default (1.0)
: {
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
};
}, [videoSaturation, videoBrightness, videoContrast]);
return ( return (
<div className="grid h-full w-full grid-rows-(--grid-layout)"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">
@ -691,17 +701,15 @@ export default function WebRTCVideo() {
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<video <video
ref={videoElm} ref={videoElm}
autoPlay={true} autoPlay
controls={false} controls={false}
onPlaying={onVideoPlaying} onPlaying={onVideoPlaying}
onPlay={onVideoPlaying} onPlay={onVideoPlaying}
muted={true} muted
playsInline playsInline
disablePictureInPicture disablePictureInPicture
controlsList="nofullscreen" controlsList="nofullscreen"
style={{ style={videoStyle}
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
}}
className={cx( className={cx(
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000", "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{ {

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

@ -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

@ -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>
);
}

18
web.go
View File

@ -97,9 +97,6 @@ func setupRouter() *gin.Engine {
// We use this to determine if the device is setup // We use this to determine if the device is setup
r.GET("/device/status", handleDeviceStatus) r.GET("/device/status", handleDeviceStatus)
// We use this to provide the UI with the device configuration
r.GET("/device/ui-config.js", handleDeviceUIConfig)
// We use this to setup the device in the welcome page // We use this to setup the device in the welcome page
r.POST("/device/setup", handleSetup) r.POST("/device/setup", handleSetup)
@ -694,21 +691,6 @@ func handleCloudState(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
func handleDeviceUIConfig(c *gin.Context) {
config, _ := json.Marshal(gin.H{
"CLOUD_API": config.CloudURL,
"DEVICE_VERSION": builtAppVersion,
})
if config == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal config"})
return
}
response := fmt.Sprintf("window.JETKVM_CONFIG = %s;", config)
c.Data(http.StatusOK, "text/javascript; charset=utf-8", []byte(response))
}
func handleSetup(c *gin.Context) { func handleSetup(c *gin.Context) {
// Check if the device is already set up // Check if the device is already set up
if config.LocalAuthMode != "" || config.HashedPassword != "" { if config.LocalAuthMode != "" || config.HashedPassword != "" {