diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index a36874d0c..acedf1020 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -139,6 +139,12 @@ "atx_power_control_reset_button": "Reset", "atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}", "atx_power_control_short_power_button": "Short Press", + "atx_indicator_force_power_off": "Force Power Off", + "atx_indicator_power_confirm_description": "Choose how to send the power button press to the machine.", + "atx_indicator_power_confirm_title": "Power Action", + "atx_indicator_reset_confirm_description": "Are you sure you want to reset the computer? This will immediately restart the machine.", + "atx_indicator_reset_confirm_title": "Reset Computer", + "atx_indicator_title": "ATX Power Control", "auth_authentication_mode": "Please select an authentication mode", "auth_authentication_mode_error": "An error occurred while setting the authentication mode", "auth_authentication_mode_invalid": "Invalid authentication mode", diff --git a/ui/src/components/ATXStateIndicator.tsx b/ui/src/components/ATXStateIndicator.tsx new file mode 100644 index 000000000..6deedcb36 --- /dev/null +++ b/ui/src/components/ATXStateIndicator.tsx @@ -0,0 +1,262 @@ +import { Fragment, useCallback, useEffect, useState } from "react"; +import { CloseButton, Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; +import { LuHardDrive, LuPower, LuRotateCcw, LuTriangleAlert } from "react-icons/lu"; + +import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; + +import { Button } from "@components/Button"; +import Modal from "@components/Modal"; +import { ConfirmDialog } from "@components/ConfirmDialog"; +import { cx } from "@/cva.config"; +import notifications from "@/notifications"; +import { m } from "@localizations/messages.js"; + +interface ATXState { + power: boolean; + hdd: boolean; +} + +export default function ATXStateIndicator({ + setDisableVideoFocusTrap, +}: { + setDisableVideoFocusTrap: (disable: boolean) => void; +}) { + const [isATXExtensionActive, setIsATXExtensionActive] = useState(false); + const [atxState, setAtxState] = useState(null); + + // Confirmation dialog state + const [showPowerConfirm, setShowPowerConfirm] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [isActionInProgress, setIsActionInProgress] = useState(false); + + const { send } = useJsonRpc(function onRequest(req) { + if (req.method === "atxState") { + setAtxState(req.params as ATXState); + } + }); + + // Check if ATX extension is active + useEffect(() => { + send("getActiveExtension", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + const extensionId = resp.result as string; + setIsATXExtensionActive(extensionId === "atx-power"); + }); + }, [send]); + + // Fetch ATX state when extension is active + useEffect(() => { + if (!isATXExtensionActive) { + return; + } + + send("getATXState", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + m.atx_power_control_get_state_error({ + error: resp.error.data || m.unknown_error(), + }), + ); + return; + } + setAtxState(resp.result as ATXState); + }); + + // Reset state when extension becomes inactive + return () => { + setAtxState(null); + }; + }, [isATXExtensionActive, send]); + + const handlePowerAction = useCallback( + (action: "power-short" | "power-long" | "reset") => { + setIsActionInProgress(true); + send("setATXPowerAction", { action }, (resp: JsonRpcResponse) => { + setIsActionInProgress(false); + if ("error" in resp) { + const actionName = + action === "reset" + ? m.atx_power_control_reset_button() + : action === "power-long" + ? m.atx_power_control_long_power_button() + : m.atx_power_control_short_power_button(); + notifications.error( + m.atx_power_control_send_action_error({ + action: actionName, + error: resp.error.data || m.unknown_error(), + }), + ); + return; + } + // Close confirmation dialogs + setShowPowerConfirm(false); + setShowResetConfirm(false); + }); + }, + [send], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + setShowPowerConfirm(false); + } + }; + + // Don't render if ATX extension is not active + if (!isATXExtensionActive) { + return null; + } + + return ( + <> + + + + + +
+
+
+ {m.atx_indicator_title()} +
+
+
+
+ + + {m.atx_power_control_power_led()} + + + + {m.atx_power_control_hdd_led()} + +
+
+
+
+
+ + {/* Power Confirmation Dialog - Custom with two action buttons */} +
+ setShowPowerConfirm(false)}> +
+
+
+
+
+ +
+ +
+
+
+
+
+
+ + {/* Reset Confirmation Dialog */} + setShowResetConfirm(false)} + title={m.atx_indicator_reset_confirm_title()} + description={m.atx_indicator_reset_confirm_description()} + variant="danger" + confirmText={m.atx_power_control_reset_button()} + cancelText={m.cancel()} + onConfirm={() => handlePowerAction("reset")} + isConfirming={isActionInProgress} + /> + + ); +} diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 364845193..a8c79176c 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -14,6 +14,7 @@ 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 ATXStateIndicator from "@components/ATXStateIndicator"; import { m } from "@localizations/messages.js"; export default function Actionbar({ @@ -227,6 +228,8 @@ export default function Actionbar({ + +