Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Kochie 4634d9c48f
Merge 315c1c9f57 into 488276f3a8 2025-07-10 00:02:16 +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
SuperQ 315c1c9f57
Add more metrics
* Configuration load success/timestamp.
* Wake-on-Lan packets/errors.

Signed-off-by: SuperQ <superq@gmail.com>
2025-06-12 09:14:25 +02:00
13 changed files with 218 additions and 68 deletions

View File

@ -9,6 +9,8 @@ import (
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type WakeOnLanDevice struct {
@ -138,6 +140,21 @@ var (
configLock = &sync.Mutex{}
)
var (
configSuccess = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_successful",
Help: "The last configuration load succeeded",
},
)
configSuccessTime = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_success_timestamp_seconds",
Help: "Timestamp of last successful config load",
},
)
)
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
@ -153,6 +170,8 @@ func LoadConfig() {
file, err := os.Open(configPath)
if err != nil {
logger.Debug().Msg("default config file doesn't exist, using default")
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
return
}
defer file.Close()
@ -161,6 +180,7 @@ func LoadConfig() {
loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed")
configSuccess.Set(0.0)
return
}
@ -181,6 +201,9 @@ func LoadConfig() {
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
logger.Info().Str("path", configPath).Msg("config loaded")
}

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},

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 }),

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

22
wol.go
View File

@ -4,6 +4,24 @@ import (
"bytes"
"encoding/binary"
"net"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
wolPackets = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_wol_sent_packets_total",
Help: "Total number of Wake-on-LAN magic packets sent.",
},
)
wolErrors = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_wol_sent_packet_errors_total",
Help: "Total number of Wake-on-LAN magic packets errors.",
},
)
)
// SendWOLMagicPacket sends a Wake-on-LAN magic packet to the specified MAC address
@ -11,6 +29,7 @@ func rpcSendWOLMagicPacket(macAddress string) error {
// Parse the MAC address
mac, err := net.ParseMAC(macAddress)
if err != nil {
wolErrors.Inc()
return ErrorfL(wolLogger, "invalid MAC address", err)
}
@ -20,6 +39,7 @@ func rpcSendWOLMagicPacket(macAddress string) error {
// Set up UDP connection
conn, err := net.Dial("udp", "255.255.255.255:9")
if err != nil {
wolErrors.Inc()
return ErrorfL(wolLogger, "failed to establish UDP connection", err)
}
defer conn.Close()
@ -27,10 +47,12 @@ func rpcSendWOLMagicPacket(macAddress string) error {
// Send the packet
_, err = conn.Write(packet)
if err != nil {
wolErrors.Inc()
return ErrorfL(wolLogger, "failed to send WOL packet", err)
}
wolLogger.Info().Str("mac", macAddress).Msg("WOL packet sent")
wolPackets.Inc()
return nil
}