diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 6e2ed99b..d1adf38d 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -977,117 +977,120 @@ export function HelpHeader({ } -export function HotspotToggle() { - let {on, setHotspot} = useHotspot(); - const disabled = on === null; - return
- setHotspot(checked)} - /> - {disabled && } -
; -} - -export function ThrottleToggle() { - let {on, setThrottle} = useThrottle(); - const disabled = on === null; - return
- setThrottle(checked)} - /> - {disabled && } -
; -} - -export function BluetoothToggle() { - let {on, setBluetooth} = useBluetooth(); - const disabled = on === null; - return
- setBluetooth(checked)} - /> - {disabled && } -
; -} - -export function DesktopToggle() { - let {on, setDesktop} = useDesktop(); +// All the hardware toggles share this shape: a Toggle whose state comes from a +// subsystem hook, disabled when the status is unknown, with an InfoPopup explaining +// why it cannot be used. `confirmStop` asks before switching off. +function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) { const [confirmOpen, setConfirmOpen] = React.useState(false); - const disabled = on === null; + const unsupported = on === null; const handleChange = (checked) => { - if (checked) { - setDesktop(true); - } else { - // Stopping the desktop kills any session on this WROLPi's own screen. + if (!checked && confirmStop) { setConfirmOpen(true); + } else { + onChange(checked); } } - const handleConfirmStop = (e) => { + const handleConfirm = (e) => { if (e) { e.preventDefault() } setConfirmOpen(false); - setDesktop(false); + onChange(false); } + const popup = unsupported ? unsupportedMessage : info; + return
- setConfirmOpen(false)} onClose={() => setConfirmOpen(false)} - onConfirm={handleConfirmStop} - header='Stop the desktop' - content={"Anyone using this WROLPi's own screen will lose their session and the display will" - + ' drop to a terminal. The desktop will return on the next reboot. Are you sure?'} - confirmButton='Stop' - /> + onConfirm={handleConfirm} + header={confirmStop.header} + content={confirmStop.content} + confirmButton={confirmStop.button} + />} - {disabled && } + {popup && }
; } +export function HotspotToggle() { + const {on, setHotspot} = useHotspot(); + return ; +} + +export function ThrottleToggle() { + const {on, setThrottle} = useThrottle(); + return ; +} + +export function BluetoothToggle() { + const {on, setBluetooth} = useBluetooth(); + return ; +} + +export function DesktopToggle() { + const {on, setDesktop} = useDesktop(); + return ; +} + export function VncToggle() { - let {on, desktopRunning, setVnc} = useVnc(); + const {on, desktopRunning, setVnc} = useVnc(); const unsupported = on === null; // VNC serves the desktop session; it cannot be started without one. Stopping // stays available so a running VNC server is never stranded on. const blockedByDesktop = !desktopRunning && on !== true; - const disabled = unsupported || blockedByDesktop; let info = null; - if (unsupported) { - info = 'VNC is not supported on this server'; - } else if (blockedByDesktop) { + if (blockedByDesktop) { info = 'Start the Desktop before starting VNC'; } else if (on === true && !desktopRunning) { info = 'The Desktop is stopped, so VNC clients will see nothing'; } - return
- setVnc(checked)} - /> - {info && } -
; + return ; } export function Toggle({label, checked, disabled, onChange, icon, popupContent = null, info = null}) { diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index d3913e19..1f8b0bf1 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1024,276 +1024,140 @@ export const useBrowseFiles = () => { return {browseFiles, openFolders, setOpenFolders, fetchFiles}; } -export const useHotspot = () => { +// Every hardware toggle works the same way: one field of the shared status object +// says whether the subsystem is on, and starting/stopping it is a Controller call. +// `on` is null when the status is unknown, which the UI renders as unsupported. +const useSubsystemToggle = ({ + statusField, + onValue = 'on', + offValues = ['off'], + start, + stop, + errorTitle, + errorDescription, + refetchStatus = false, + }) => { const [on, setOn] = useState(null); - const [inUse, setInUse] = useState(false); - const {status} = useContext(StatusContext); - // Hotspot is unsupported on Docker. - const {dockerized, hotspot_ssid} = status; + const {status, fetchStatus} = useContext(StatusContext); + const value = status?.[statusField]; useEffect(() => { - if (status && status['hotspot_status']) { - const {hotspot_status} = status; - if (hotspot_status === 'connected') { - setOn(true); - setInUse(false); - } else if (hotspot_status === 'in_use') { - setInUse(true); - setOn(false); - } else if (hotspot_status === 'disconnected' || hotspot_status === 'off') { - setOn(false); - setInUse(false); - } else { - setOn(null); - } + if (value === onValue) { + setOn(true); + } else if (offValues.includes(value)) { + setOn(false); + } else { + setOn(null); } - }, [status]); + }, [value]); - const localSetHotspot = async (enable) => { + const setSubsystem = async (enable) => { + const previous = on; setOn(null); try { if (enable) { - await startHotspot(); + await start(); } else { - await stopHotspot(); + await stop(); } } catch (e) { - console.error('Hotspot error:', e); + console.error(`${errorTitle}:`, e); toast({ type: 'error', - title: 'Hotspot Error', - description: e.message || 'Could not modify hotspot. See server logs.', + title: errorTitle, + description: e.message || errorDescription, time: 5000, }); + // Nothing changed, so the status poll will not re-sync `on`; without this + // the toggle stays disabled and reads as unsupported until it remounts. + setOn(previous); + } + if (refetchStatus) { + await fetchStatus(); } } - return {on, inUse, hotspotSsid: hotspot_ssid, setOn, setHotspot: localSetHotspot, dockerized}; + return {on, setOn, setSubsystem}; } -export const useDownloads = () => { - const [onceDownloads, setOnceDownloads] = useState(null); - const [recurringDownloads, setRecurringDownloads] = useState(null); - const [pendingOnceDownloads, setPendingOnceDownloads] = useState(null); +export const useHotspot = () => { + const {status} = useContext(StatusContext); + // Hotspot is unsupported on Docker. + const {dockerized, hotspot_ssid, hotspot_status} = status; + const {on, setOn, setSubsystem} = useSubsystemToggle({ + statusField: 'hotspot_status', + onValue: 'connected', + // The WiFi device being in use for a network is a kind of "off": the hotspot + // is not running, but starting it takes the user off that network. + offValues: ['disconnected', 'off', 'in_use'], + start: startHotspot, + stop: stopHotspot, + errorTitle: 'Hotspot Error', + errorDescription: 'Could not modify hotspot. See server logs.', + }); - const fetchDownloads = async () => { - try { - const data = await getDownloads(); - setOnceDownloads(data['once_downloads']); - setRecurringDownloads(data['recurring_downloads']); - setPendingOnceDownloads(data['pending_once_downloads']); - } catch (e) { - console.error(e); - // Display errors. - setOnceDownloads(undefined); - setRecurringDownloads(undefined); - setPendingOnceDownloads(undefined); - } - } - - useRecurringTimeout(fetchDownloads, 3 * 1000); - - return {onceDownloads, recurringDownloads, pendingOnceDownloads, fetchDownloads} -} - -export const useConfigs = () => { - const [configs, setConfigs] = useState(null); - const [loading, setLoading] = useState(false); - - const fetchConfigs = async () => { - setLoading(true); - console.log('fetching configs...'); - try { - const data = await getConfigs(); - setConfigs(data['configs']); - console.debug('fetching configs successful'); - } catch (e) { - // Ignore SyntaxError because they happen when the API is down. - if (!(e instanceof SyntaxError)) { - console.error(e); - } - setConfigs(undefined); - } finally { - setLoading(false); - } - } - - const importConfig = async (fileName) => { - try { - await postImportConfig(fileName); - } catch (e) { - console.error(e); - } - } - - const saveConfig = async (fileName) => { - try { - await postDumpConfig(fileName); - } catch (e) { - console.error(e); - } - } - - return {configs, loading, fetchConfigs, importConfig, saveConfig} + return { + on, + inUse: hotspot_status === 'in_use', + hotspotSsid: hotspot_ssid, + setOn, + setHotspot: setSubsystem, + dockerized, + }; } export const useThrottle = () => { - const [on, setOn] = useState(null); - const {status, fetchStatus} = React.useContext(StatusContext); + const {on, setOn, setSubsystem} = useSubsystemToggle({ + statusField: 'throttle_status', + onValue: 'powersave', + offValues: ['ondemand'], + start: enableThrottle, + stop: disableThrottle, + errorTitle: 'Throttle Error', + errorDescription: 'Could not modify throttle. See server logs.', + // The governor is read from sysfs, so refetch rather than wait for the poll. + refetchStatus: true, + }); - useEffect(() => { - const throttleStatus = status?.throttle_status; - if (throttleStatus === 'powersave') { - setOn(true); - } else if (throttleStatus === 'ondemand') { - setOn(false); - } else { - setOn(null); - } - }, [status?.throttle_status]); - - const localSetThrottle = async (enable) => { - setOn(null); - try { - if (enable) { - await enableThrottle(); - } else { - await disableThrottle(); - } - } catch (e) { - console.error('Throttle error:', e); - toast({ - type: 'error', - title: 'Throttle Error', - description: e.message || 'Could not modify throttle. See server logs.', - time: 5000, - }); - } - // Refetch status to get updated throttle state - await fetchStatus(); - } - - return {on, setOn, setThrottle: localSetThrottle}; + return {on, setOn, setThrottle: setSubsystem}; } export const useBluetooth = () => { - const [on, setOn] = useState(null); - const {status} = useContext(StatusContext); + const {on, setSubsystem} = useSubsystemToggle({ + statusField: 'bluetooth_status', + start: unblockBluetooth, + stop: blockBluetooth, + errorTitle: 'Bluetooth Error', + errorDescription: 'Could not modify Bluetooth. See server logs.', + }); - useEffect(() => { - const bluetoothStatus = status?.bluetooth_status; - if (bluetoothStatus === 'on') { - setOn(true); - } else if (bluetoothStatus === 'off') { - setOn(false); - } else { - setOn(null); - } - }, [status?.bluetooth_status]); - - const localSetBluetooth = async (enable) => { - setOn(null); - try { - if (enable) { - await unblockBluetooth(); - } else { - await blockBluetooth(); - } - } catch (e) { - console.error('Bluetooth error:', e); - toast({ - type: 'error', - title: 'Bluetooth Error', - description: e.message || 'Could not modify Bluetooth. See server logs.', - time: 5000, - }); - } - } - - return {on, setBluetooth: localSetBluetooth}; + return {on, setBluetooth: setSubsystem}; } export const useDesktop = () => { - const [on, setOn] = useState(null); - const {status} = useContext(StatusContext); + const {on, setSubsystem} = useSubsystemToggle({ + statusField: 'desktop_status', + start: startDesktop, + stop: stopDesktop, + errorTitle: 'Desktop Error', + errorDescription: 'Could not start/stop the desktop. See server logs.', + }); - useEffect(() => { - const desktopStatus = status?.desktop_status; - if (desktopStatus === 'on') { - setOn(true); - } else if (desktopStatus === 'off') { - setOn(false); - } else { - setOn(null); - } - }, [status?.desktop_status]); - - const localSetDesktop = async (running) => { - const previous = on; - setOn(null); - try { - if (running) { - await startDesktop(); - } else { - await stopDesktop(); - } - } catch (e) { - console.error('Desktop error:', e); - toast({ - type: 'error', - title: 'Desktop Error', - description: e.message || 'Could not start/stop the desktop. See server logs.', - time: 5000, - }); - // The desktop did not change, so the status poll will not re-sync `on`; - // restore it or the toggle stays disabled. - setOn(previous); - } - } - - return {on, setDesktop: localSetDesktop}; + return {on, setDesktop: setSubsystem}; } export const useVnc = () => { - const [on, setOn] = useState(null); const {status} = useContext(StatusContext); + const {on, setSubsystem} = useSubsystemToggle({ + statusField: 'vnc_status', + start: startVnc, + stop: stopVnc, + errorTitle: 'VNC Error', + errorDescription: 'Could not start/stop VNC. See server logs.', + }); + // VNC serves the desktop session, so it cannot be started without one. - const desktopRunning = status?.desktop_status === 'on'; - - useEffect(() => { - const vncStatus = status?.vnc_status; - if (vncStatus === 'on') { - setOn(true); - } else if (vncStatus === 'off') { - setOn(false); - } else { - setOn(null); - } - }, [status?.vnc_status]); - - const localSetVnc = async (running) => { - const previous = on; - setOn(null); - try { - if (running) { - await startVnc(); - } else { - await stopVnc(); - } - } catch (e) { - console.error('VNC error:', e); - toast({ - type: 'error', - title: 'VNC Error', - description: e.message || 'Could not start/stop VNC. See server logs.', - time: 5000, - }); - // The status poll will not re-sync `on` because VNC did not change. - setOn(previous); - } - } - - return {on, desktopRunning, setVnc: localSetVnc}; + return {on, desktopRunning: status?.desktop_status === 'on', setVnc: setSubsystem}; } export const useSearchDirectories = (value) => { diff --git a/app/src/hooks/useSubsystemToggle.test.js b/app/src/hooks/useSubsystemToggle.test.js new file mode 100644 index 00000000..d08c241f --- /dev/null +++ b/app/src/hooks/useSubsystemToggle.test.js @@ -0,0 +1,129 @@ +import React from 'react'; +import {act, renderHook, waitFor} from '@testing-library/react'; +import {useBluetooth, useDesktop, useHotspot, useThrottle, useVnc} from './customHooks'; +import {StatusContext} from '../contexts/contexts'; + +jest.mock('../api/controller', () => ({ + unblockBluetooth: jest.fn().mockResolvedValue({}), + blockBluetooth: jest.fn().mockResolvedValue({}), + startHotspot: jest.fn().mockResolvedValue({}), + stopHotspot: jest.fn().mockResolvedValue({}), + startDesktop: jest.fn().mockResolvedValue({}), + stopDesktop: jest.fn().mockResolvedValue({}), + startVnc: jest.fn().mockResolvedValue({}), + stopVnc: jest.fn().mockResolvedValue({}), + enableThrottle: jest.fn().mockResolvedValue({}), + disableThrottle: jest.fn().mockResolvedValue({}), + getControllerStats: jest.fn().mockResolvedValue({}), +})); + +jest.mock('react-semantic-toasts-2', () => ({toast: jest.fn()})); + +const controllerApi = require('../api/controller'); + +const fetchStatus = jest.fn(); + +const makeWrapper = (status) => ({children}) => ( + {children} +); + +beforeEach(() => { + jest.clearAllMocks(); + Object.values(controllerApi).forEach(fn => fn.mockResolvedValue && fn.mockResolvedValue({})); +}); + +describe('subsystem toggle hooks', () => { + // Each hook reads a different status field with different values for on/off. + const cases = [ + { + name: 'bluetooth', hook: useBluetooth, setter: 'setBluetooth', + status: {bluetooth_status: 'on'}, offStatus: {bluetooth_status: 'off'}, + start: 'unblockBluetooth', stop: 'blockBluetooth', + }, + { + name: 'desktop', hook: useDesktop, setter: 'setDesktop', + status: {desktop_status: 'on'}, offStatus: {desktop_status: 'off'}, + start: 'startDesktop', stop: 'stopDesktop', + }, + { + name: 'vnc', hook: useVnc, setter: 'setVnc', + status: {vnc_status: 'on'}, offStatus: {vnc_status: 'off'}, + start: 'startVnc', stop: 'stopVnc', + }, + { + name: 'throttle', hook: useThrottle, setter: 'setThrottle', + status: {throttle_status: 'powersave'}, offStatus: {throttle_status: 'ondemand'}, + start: 'enableThrottle', stop: 'disableThrottle', + }, + { + name: 'hotspot', hook: useHotspot, setter: 'setHotspot', + status: {hotspot_status: 'connected'}, offStatus: {hotspot_status: 'disconnected'}, + start: 'startHotspot', stop: 'stopHotspot', + }, + ]; + + cases.forEach(({name, hook, setter, status, offStatus, start, stop}) => { + describe(name, () => { + it('reports on and off from the status field', async () => { + const {result} = renderHook(hook, {wrapper: makeWrapper(status)}); + await waitFor(() => expect(result.current.on).toBe(true)); + + const {result: offResult} = renderHook(hook, {wrapper: makeWrapper(offStatus)}); + await waitFor(() => expect(offResult.current.on).toBe(false)); + }); + + it('reports unknown status as null so the UI can disable the toggle', async () => { + const {result} = renderHook(hook, {wrapper: makeWrapper({})}); + await waitFor(() => expect(result.current.on).toBe(null)); + }); + + it('starts and stops through the Controller', async () => { + const {result} = renderHook(hook, {wrapper: makeWrapper(offStatus)}); + await waitFor(() => expect(result.current.on).toBe(false)); + + await act(async () => await result.current[setter](true)); + expect(controllerApi[start]).toHaveBeenCalled(); + + await act(async () => await result.current[setter](false)); + expect(controllerApi[stop]).toHaveBeenCalled(); + }); + + it('restores the previous state when the request fails', async () => { + // Otherwise `on` stays null, and because the status poll sees no change + // the toggle is left disabled and reading as unsupported until remount. + controllerApi[stop].mockRejectedValue(new Error('boom')); + const {result} = renderHook(hook, {wrapper: makeWrapper(status)}); + await waitFor(() => expect(result.current.on).toBe(true)); + + await act(async () => await result.current[setter](false)); + + expect(result.current.on).toBe(true); + }); + }); + }); + + it('throttle refetches status because the governor is read from sysfs', async () => { + const {result} = renderHook(useThrottle, {wrapper: makeWrapper({throttle_status: 'ondemand'})}); + await waitFor(() => expect(result.current.on).toBe(false)); + + await act(async () => await result.current.setThrottle(true)); + + expect(fetchStatus).toHaveBeenCalled(); + }); + + it('hotspot reports a WiFi device that is in use for a network', async () => { + const {result} = renderHook(useHotspot, {wrapper: makeWrapper({hotspot_status: 'in_use'})}); + + await waitFor(() => expect(result.current.inUse).toBe(true)); + // In use means the hotspot is not running, but starting it leaves that network. + expect(result.current.on).toBe(false); + }); + + it('vnc exposes whether the desktop it serves is running', async () => { + const {result} = renderHook(useVnc, {wrapper: makeWrapper({vnc_status: 'off', desktop_status: 'on'})}); + await waitFor(() => expect(result.current.desktopRunning).toBe(true)); + + const {result: stopped} = renderHook(useVnc, {wrapper: makeWrapper({vnc_status: 'off', desktop_status: 'off'})}); + await waitFor(() => expect(stopped.current.desktopRunning).toBe(false)); + }); +}); diff --git a/controller/api/admin.py b/controller/api/admin.py index 6d54516f..2839df3f 100644 --- a/controller/api/admin.py +++ b/controller/api/admin.py @@ -69,6 +69,20 @@ from controller.lib.samba import ( router = APIRouter(tags=["admin"]) +def _raise_for_action(result: dict): + """ + Turn a failed subsystem action into an HTTPException. + + Actions all report `{"success": bool, "error": str}`. A precondition failure + (VNC needs a running desktop) is a 409 so callers can tell it from a command + that was attempted and failed. + """ + if result.get("success"): + return + status_code = 409 if result.get("precondition_failed") else 500 + raise HTTPException(status_code=status_code, detail=result.get("error", "Failed")) + + # --- Hotspot --- @router.get("/api/hotspot/status", response_model=HotspotStatusResponse) @@ -119,8 +133,7 @@ async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSet async def hotspot_start() -> HotspotActionResponse: """Start the WiFi hotspot.""" result = start_hotspot() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return HotspotActionResponse(**result) @@ -128,8 +141,7 @@ async def hotspot_start() -> HotspotActionResponse: async def hotspot_stop() -> HotspotActionResponse: """Stop the WiFi hotspot by turning the WiFi radio off.""" result = stop_hotspot() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return HotspotActionResponse(**result) @@ -145,8 +157,7 @@ async def bluetooth_status() -> BluetoothStatusResponse: async def bluetooth_unblock() -> BluetoothActionResponse: """Unblock the Bluetooth radio (turn it on).""" result = await unblock_bluetooth() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return BluetoothActionResponse(**result) @@ -154,8 +165,7 @@ async def bluetooth_unblock() -> BluetoothActionResponse: async def bluetooth_block() -> BluetoothActionResponse: """Block the Bluetooth radio (turn it off).""" result = await block_bluetooth() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return BluetoothActionResponse(**result) @@ -171,8 +181,7 @@ async def desktop_status() -> DesktopStatusResponse: async def desktop_start() -> DesktopActionResponse: """Start the graphical desktop until stopped or reboot.""" result = await start_desktop() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return DesktopActionResponse(**result) @@ -180,8 +189,7 @@ async def desktop_start() -> DesktopActionResponse: async def desktop_stop() -> DesktopActionResponse: """Stop the graphical desktop; it returns on the next boot (fail open).""" result = await stop_desktop() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return DesktopActionResponse(**result) @@ -197,10 +205,7 @@ async def vnc_status() -> VncStatusResponse: async def vnc_start() -> VncActionResponse: """Start the VNC server; requires a running desktop.""" result = await start_vnc() - if not result.get("success"): - # 409 distinguishes "the desktop is not running" from a systemctl failure. - status_code = 409 if result.get("precondition_failed") else 500 - raise HTTPException(status_code=status_code, detail=result.get("error", "Failed")) + _raise_for_action(result) return VncActionResponse(success=True) @@ -208,8 +213,7 @@ async def vnc_start() -> VncActionResponse: async def vnc_stop() -> VncActionResponse: """Stop the VNC server; allowed even when the desktop is stopped.""" result = await stop_vnc() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return VncActionResponse(success=True) @@ -251,8 +255,7 @@ async def throttle_status() -> ThrottleStatusResponse: async def throttle_enable() -> ThrottleActionResponse: """Enable CPU throttle (powersave mode).""" result = enable_throttle() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return ThrottleActionResponse(**result) @@ -260,8 +263,7 @@ async def throttle_enable() -> ThrottleActionResponse: async def throttle_disable() -> ThrottleActionResponse: """Disable CPU throttle (normal performance).""" result = disable_throttle() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return ThrottleActionResponse(**result) @@ -277,8 +279,7 @@ async def timezone_status() -> TimezoneStatusResponse: async def timezone_set(request: TimezoneSetRequest) -> TimezoneSetResponse: """Set the system timezone.""" result = set_timezone(request.timezone) - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return TimezoneSetResponse(**result) @@ -294,8 +295,7 @@ async def system_shutdown() -> SystemActionResponse: ) result = shutdown_system() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return SystemActionResponse(**result) @@ -309,8 +309,7 @@ async def system_reboot() -> SystemActionResponse: ) result = reboot_system() - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + _raise_for_action(result) return SystemActionResponse(**result) diff --git a/controller/templates/index.html b/controller/templates/index.html index 49a33273..2aa07af3 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -972,10 +972,10 @@

System Actions

{% if not docker_mode %}
- - - - + + + +
Hotspot Settings @@ -1384,48 +1384,101 @@ } } - // --- Hotspot --- - let hotspotEnabled = null; + // --- Hardware toggle buttons --- + // The Hotspot, Bluetooth, Desktop and VNC buttons all behave the same way: read a + // status endpoint, label the button from it, and POST start/stop when clicked. + const TOGGLE_BUTTONS = { + hotspot: { + id: 'hotspot-btn', label: 'Hotspot', + statusUrl: 'api/hotspot/status', startUrl: 'api/hotspot/start', stopUrl: 'api/hotspot/stop', + isOn: data => data.enabled, + }, + bluetooth: { + id: 'bluetooth-btn', label: 'Bluetooth', + statusUrl: 'api/bluetooth/status', startUrl: 'api/bluetooth/unblock', stopUrl: 'api/bluetooth/block', + isOn: data => data.enabled, + startText: 'Enable Bluetooth', stopText: 'Disable Bluetooth', + }, + desktop: { + id: 'desktop-btn', label: 'Desktop', + statusUrl: 'api/desktop/status', startUrl: 'api/desktop/start', stopUrl: 'api/desktop/stop', + isOn: data => data.running, + confirmStop: 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session' + + ' and the display will drop to a terminal. The desktop will return on the next reboot.', + // Starting or stopping the desktop changes whether VNC can run. + alsoRefresh: ['vnc'], + }, + vnc: { + id: 'vnc-btn', label: 'VNC', + statusUrl: 'api/vnc/status', startUrl: 'api/vnc/start', stopUrl: 'api/vnc/stop', + isOn: data => data.running, + // VNC serves the desktop session, so it cannot be started without one. + canStart: data => data.can_start, + onTitle: data => data.desktop_running ? '' : 'The Desktop is stopped, so VNC clients see nothing', + }, + }; - async function updateHotspotButton() { - const btn = document.getElementById('hotspot-btn'); + const toggleStates = {}; + + async function updateToggleButton(name) { + const config = TOGGLE_BUTTONS[name]; + const btn = document.getElementById(config.id); if (!btn) return; + const startText = config.startText || `Start ${config.label}`; + const stopText = config.stopText || `Stop ${config.label}`; try { - const data = await apiCall('api/hotspot/status'); - hotspotEnabled = data.enabled; + const data = await apiCall(config.statusUrl); + toggleStates[name] = config.isOn(data); + btn.title = ''; if (!data.available) { - btn.textContent = 'Hotspot Unavailable'; + btn.textContent = `${config.label} Unavailable`; btn.disabled = true; btn.className = 'btn'; - } else if (data.enabled) { - btn.textContent = 'Stop Hotspot'; + btn.title = data.reason || ''; + } else if (toggleStates[name]) { + btn.textContent = stopText; btn.disabled = false; btn.className = 'btn btn-warning'; + if (config.onTitle) btn.title = config.onTitle(data); + } else if (config.canStart && !config.canStart(data)) { + // Installed and stopped, but something else must happen first. + btn.textContent = startText; + btn.disabled = true; + btn.className = 'btn'; + btn.title = data.reason || ''; } else { - btn.textContent = 'Start Hotspot'; + btn.textContent = startText; btn.disabled = false; btn.className = 'btn btn-primary'; } } catch (e) { - btn.textContent = 'Hotspot Unavailable'; + btn.textContent = `${config.label} Unavailable`; btn.disabled = true; } } - async function toggleHotspot() { - const btn = document.getElementById('hotspot-btn'); + async function refreshToggleButtons() { + await Promise.all(Object.keys(TOGGLE_BUTTONS).map(name => updateToggleButton(name))); + } + + async function toggleSubsystem(name) { + const config = TOGGLE_BUTTONS[name]; + const btn = document.getElementById(config.id); + const on = toggleStates[name]; + if (on && config.confirmStop && !confirm(config.confirmStop)) { + return; + } btn.disabled = true; btn.innerHTML = ''; try { - if (hotspotEnabled) { - await apiCall('api/hotspot/stop', 'POST'); - } else { - await apiCall('api/hotspot/start', 'POST'); - } + await apiCall(on ? config.stopUrl : config.startUrl, 'POST'); } catch (e) { - alert(`Hotspot error: ${e.message}`); + alert(`${config.label} error: ${e.message}`); + } + await updateToggleButton(name); + for (const other of config.alsoRefresh || []) { + await updateToggleButton(other); } - await updateHotspotButton(); } async function loadHotspotSettings() { @@ -1496,160 +1549,13 @@ device: document.getElementById('hotspot-device').value, protocol: document.getElementById('hotspot-protocol').value || null, }); - await updateHotspotButton(); + await updateToggleButton('hotspot'); } catch (e) { alert(`Failed to save hotspot settings: ${e.message}`); } btn.disabled = false; } - // --- Bluetooth --- - let bluetoothEnabled = null; - - async function updateBluetoothButton() { - const btn = document.getElementById('bluetooth-btn'); - if (!btn) return; - try { - const data = await apiCall('api/bluetooth/status'); - bluetoothEnabled = data.enabled; - if (!data.available) { - btn.textContent = 'Bluetooth Unavailable'; - btn.disabled = true; - btn.className = 'btn'; - } else if (data.enabled) { - btn.textContent = 'Disable Bluetooth'; - btn.disabled = false; - btn.className = 'btn btn-warning'; - } else { - btn.textContent = 'Enable Bluetooth'; - btn.disabled = false; - btn.className = 'btn btn-primary'; - } - } catch (e) { - btn.textContent = 'Bluetooth Unavailable'; - btn.disabled = true; - } - } - - async function toggleBluetooth() { - const btn = document.getElementById('bluetooth-btn'); - btn.disabled = true; - btn.innerHTML = ''; - try { - if (bluetoothEnabled) { - await apiCall('api/bluetooth/block', 'POST'); - } else { - await apiCall('api/bluetooth/unblock', 'POST'); - } - } catch (e) { - alert(`Bluetooth error: ${e.message}`); - } - await updateBluetoothButton(); - } - - // --- Desktop --- - let desktopRunning = null; - - async function updateDesktopButton() { - const btn = document.getElementById('desktop-btn'); - if (!btn) return; - try { - const data = await apiCall('api/desktop/status'); - desktopRunning = data.running; - if (!data.available) { - btn.textContent = 'Desktop Unavailable'; - btn.disabled = true; - btn.className = 'btn'; - } else if (data.running) { - btn.textContent = 'Stop Desktop'; - btn.disabled = false; - btn.className = 'btn btn-warning'; - } else { - btn.textContent = 'Start Desktop'; - btn.disabled = false; - btn.className = 'btn btn-primary'; - } - } catch (e) { - btn.textContent = 'Desktop Unavailable'; - btn.disabled = true; - } - } - - async function toggleDesktop() { - const btn = document.getElementById('desktop-btn'); - if (desktopRunning && !confirm( - 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session' - + ' and the display will drop to a terminal. The desktop will return on the next reboot.')) { - return; - } - btn.disabled = true; - btn.innerHTML = ''; - try { - if (desktopRunning) { - await apiCall('api/desktop/stop', 'POST'); - } else { - await apiCall('api/desktop/start', 'POST'); - } - } catch (e) { - alert(`Desktop error: ${e.message}`); - } - await updateDesktopButton(); - await updateVncButton(); - } - - // --- VNC --- - let vncRunning = null; - - async function updateVncButton() { - const btn = document.getElementById('vnc-btn'); - if (!btn) return; - try { - const data = await apiCall('api/vnc/status'); - vncRunning = data.running; - if (!data.available) { - btn.textContent = 'VNC Unavailable'; - btn.disabled = true; - btn.className = 'btn'; - btn.title = data.reason || ''; - } else if (data.running) { - btn.textContent = 'Stop VNC'; - btn.disabled = false; - btn.className = 'btn btn-warning'; - // VNC serves the desktop session, so say when there is nothing to serve. - btn.title = data.desktop_running ? '' : 'The Desktop is stopped, so VNC clients see nothing'; - } else if (!data.can_start) { - btn.textContent = 'Start VNC'; - btn.disabled = true; - btn.className = 'btn'; - btn.title = data.reason || ''; - } else { - btn.textContent = 'Start VNC'; - btn.disabled = false; - btn.className = 'btn btn-primary'; - btn.title = ''; - } - } catch (e) { - btn.textContent = 'VNC Unavailable'; - btn.disabled = true; - } - } - - async function toggleVnc() { - const btn = document.getElementById('vnc-btn'); - btn.disabled = true; - btn.innerHTML = ''; - try { - if (vncRunning) { - await apiCall('api/vnc/stop', 'POST'); - } else { - await apiCall('api/vnc/start', 'POST'); - } - } catch (e) { - alert(`VNC error: ${e.message}`); - } - await updateVncButton(); - } - async function waitForController(maxWaitMs = 60000) { const start = Date.now(); while (Date.now() - start < maxWaitMs) { @@ -3030,10 +2936,7 @@ const updateInterval = 10000; // Fetch hardware button status immediately - updateHotspotButton(); - updateBluetoothButton(); - updateDesktopButton(); - updateVncButton(); + refreshToggleButtons(); loadHotspotSettings(); // Initial update after a short delay (to not overlap with initial load) @@ -3050,10 +2953,7 @@ updateServicesTable(); loadDisks(); loadSmart(); - updateHotspotButton(); - updateBluetoothButton(); - updateDesktopButton(); - updateVncButton(); + refreshToggleButtons(); }, updateInterval); }