Skip to content

Commit cc4e9c8

Browse files
committed
enchants reset/shutdown ux
1 parent 08f14c2 commit cc4e9c8

4 files changed

Lines changed: 176 additions & 68 deletions

File tree

frieren-front/src/components/SystemActionsDropdown/index.jsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,45 @@ import useUserLogoutMutation from '@src/hooks/useUserLogoutMutation';
1313
import Icon from '@src/components/Icon';
1414
import ConfirmationModal from '@src/components/ConfirmationModal';
1515
import AboutModal from '@src/components/AboutModal';
16+
import SystemStatusModal from '@src/components/SystemStatusModal';
1617

1718
/**
1819
* Actions for system control.
1920
*/
2021
const RESET_ACTION = 'reset';
2122
const SHUTDOWN_ACTION = 'shutdown';
2223

24+
const ACTION_TO_STATUS = {
25+
[RESET_ACTION]: 'restart',
26+
[SHUTDOWN_ACTION]: 'shutdown',
27+
};
28+
2329
/**
2430
* Dropdown component for system actions including reset, shutdown, and logout.
25-
* Provides confirmation modals for reset and shutdown actions.
31+
* Provides confirmation modals for reset and shutdown actions, and a status
32+
* modal that tracks device state during restart or shutdown operations.
2633
*
27-
* @returns {ReactElement} The system actions dropdown with confirmation modals.
34+
* @returns {ReactElement} The system actions dropdown with confirmation and status modals.
2835
*/
2936
const SystemActionsDropdown = () => {
30-
const { mutate: resetMutation } = useResetMutation();
31-
const { mutate: shutDownMutation } = useShutDownMutation();
37+
const resetMutation = useResetMutation();
38+
const shutDownMutation = useShutDownMutation();
3239
const { mutate: logoffMutation } = useUserLogoutMutation();
3340
const [showConfirmModal, setShowConfirmModal] = useState(false);
3441
const [currentAction, setCurrentAction] = useState('');
42+
const [systemStatus, setSystemStatus] = useState(null);
3543
const [showAbout, setShowAbout] = useState(false);
3644

3745
const handleConfirm = () => {
38-
if (currentAction === RESET_ACTION) {
39-
resetMutation();
40-
} else if (currentAction === SHUTDOWN_ACTION) {
41-
shutDownMutation();
42-
}
43-
handleCloseModal();
46+
const mutation = currentAction === RESET_ACTION ? resetMutation : shutDownMutation;
47+
const status = ACTION_TO_STATUS[currentAction];
48+
49+
mutation.mutate(undefined, {
50+
onSuccess: () => {
51+
handleCloseModal();
52+
setSystemStatus(status);
53+
},
54+
});
4455
};
4556

4657
const handleOpenModal = (action) => {
@@ -53,6 +64,8 @@ const SystemActionsDropdown = () => {
5364
setCurrentAction('');
5465
};
5566

67+
const isActionPending = resetMutation.isPending || shutDownMutation.isPending;
68+
5669
return (
5770
<>
5871
<NavDropdown title={<Icon name={'more-vertical'} />} align={'end'}>
@@ -76,11 +89,13 @@ const SystemActionsDropdown = () => {
7689
onConfirm={handleConfirm}
7790
title={`Confirm ${currentAction}`}
7891
description={`Are you sure you want to ${currentAction} the hardware?`}
92+
isConfirmLoading={isActionPending}
7993
/>
8094
<AboutModal
8195
show={showAbout}
8296
onHide={() => setShowAbout(false)}
8397
/>
98+
<SystemStatusModal action={systemStatus} />
8499
</>
85100
);
86101
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Project: Frieren Framework
3+
* Copyright (C) 2026 DSR! <xchwarze@gmail.com>
4+
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
5+
* More info at: https://github.com/xchwarze/frieren
6+
*/
7+
import { useState, useEffect, useCallback } from 'react';
8+
import { Modal, Spinner } from 'react-bootstrap';
9+
import { useQueryClient } from '@tanstack/react-query';
10+
import { useSetAtom } from 'jotai';
11+
import { useLocation } from 'wouter';
12+
import PropTypes from 'prop-types';
13+
14+
import authAtom from '@src/atoms/authAtom.js';
15+
import Icon from '@src/components/Icon';
16+
import { fetchPost } from '@src/services/fetchService.js';
17+
18+
const REBOOT_POLL_INTERVAL = 20000;
19+
const SHUTDOWN_DISCONNECT_DELAY = 30000;
20+
21+
/**
22+
* Checks whether a fetch error indicates the device is unreachable (network failure or timeout).
23+
*
24+
* @param {Error} error - The error thrown by fetchPost.
25+
* @return {boolean} True if the device could not be reached at all.
26+
*/
27+
const isDeviceUnreachable = (error) =>
28+
error instanceof TypeError || error.message === 'Request timed out';
29+
30+
/**
31+
* Modal that displays device status during restart or shutdown operations.
32+
* On restart: polls the server every 20 seconds and redirects to login when the device responds.
33+
* On shutdown: shows a progress message, then a disconnect prompt after 30 seconds.
34+
*
35+
* @param {string|null} action - Current action: 'restart', 'shutdown', or null.
36+
* @returns {ReactElement|null} The status modal or null when no action is active.
37+
*/
38+
const SystemStatusModal = ({ action }) => {
39+
const setAuth = useSetAtom(authAtom);
40+
const [, setLocation] = useLocation();
41+
const queryClient = useQueryClient();
42+
const [canDisconnect, setCanDisconnect] = useState(false);
43+
44+
const redirectToLogin = useCallback(() => {
45+
setAuth(false);
46+
setLocation('/');
47+
queryClient.clear();
48+
}, [setAuth, setLocation, queryClient]);
49+
50+
useEffect(() => {
51+
if (action !== 'restart') return;
52+
53+
let timeoutId;
54+
let cancelled = false;
55+
56+
const poll = async () => {
57+
try {
58+
await fetchPost({ module: 'header', action: 'serverPing' });
59+
if (!cancelled) redirectToLogin();
60+
} catch (error) {
61+
if (!cancelled && !isDeviceUnreachable(error)) {
62+
redirectToLogin();
63+
return;
64+
}
65+
}
66+
67+
if (!cancelled) {
68+
timeoutId = setTimeout(poll, REBOOT_POLL_INTERVAL);
69+
}
70+
};
71+
72+
timeoutId = setTimeout(poll, REBOOT_POLL_INTERVAL);
73+
74+
return () => {
75+
cancelled = true;
76+
clearTimeout(timeoutId);
77+
};
78+
}, [action, redirectToLogin]);
79+
80+
useEffect(() => {
81+
if (action !== 'shutdown') return;
82+
83+
const timeoutId = setTimeout(() => {
84+
setCanDisconnect(true);
85+
}, SHUTDOWN_DISCONNECT_DELAY);
86+
87+
return () => clearTimeout(timeoutId);
88+
}, [action]);
89+
90+
if (!action) {
91+
return null;
92+
}
93+
94+
if (action === 'restart') {
95+
return (
96+
<Modal show backdrop={'static'} keyboard={false} centered>
97+
<Modal.Body className={'text-center py-5'}>
98+
<Spinner animation={'border'} className={'mb-3'} />
99+
<h5>Restarting device...</h5>
100+
<p className={'text-muted mb-0'}>Checking connectivity every 20 seconds</p>
101+
</Modal.Body>
102+
</Modal>
103+
);
104+
}
105+
106+
return (
107+
<Modal show backdrop={'static'} keyboard={false} centered>
108+
<Modal.Body className={'text-center py-5'}>
109+
{canDisconnect ? (
110+
<>
111+
<div className={'mb-3'}>
112+
<Icon name={'check-circle'} style={{ fontSize: '2.5rem' }} />
113+
</div>
114+
<h5>You can now disconnect the device</h5>
115+
</>
116+
) : (
117+
<>
118+
<Spinner animation={'border'} className={'mb-3'} />
119+
<h5>Shutting down device...</h5>
120+
</>
121+
)}
122+
</Modal.Body>
123+
</Modal>
124+
);
125+
};
126+
127+
SystemStatusModal.propTypes = {
128+
action: PropTypes.oneOf(['restart', 'shutdown']),
129+
};
130+
131+
export default SystemStatusModal;

frieren-front/src/hooks/useResetMutation.js

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,21 @@
44
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
55
* More info at: https://github.com/xchwarze/frieren
66
*/
7-
import { useQueryClient } from '@tanstack/react-query';
8-
import { useSetAtom } from 'jotai'
9-
import { useLocation } from 'wouter';
10-
import { toast } from 'react-toastify';
11-
12-
import authAtom from '@src/atoms/authAtom.js';
137
import { fetchPost } from '@src/services/fetchService.js';
148
import useAuthenticatedMutation from '@src/hooks/useAuthenticatedMutation.js';
159

1610
/**
17-
* Restart the real hardware.
11+
* Sends a reboot command to the hardware.
1812
*
19-
* @return {Function} The mutation function
13+
* @return {Object} The mutation object for hardware reset.
2014
*/
21-
const useResetMutation = () => {
22-
const queryClient = useQueryClient();
23-
const setAuth = useSetAtom(authAtom)
24-
const [, setLocation] = useLocation();
25-
26-
return useAuthenticatedMutation({
27-
mutationFn: () => fetchPost({
28-
module: 'header',
29-
action: 'resetHardware',
30-
}),
31-
onSuccess: () => {
32-
setAuth(false);
33-
setLocation('/');
34-
queryClient.clear();
35-
toast.success('Device is rebooting');
36-
},
37-
onError: () => {
38-
toast.error('Reboot failed');
39-
}
40-
});
41-
};
15+
const useResetMutation = () => (
16+
useAuthenticatedMutation({
17+
mutationFn: () => fetchPost({
18+
module: 'header',
19+
action: 'resetHardware',
20+
}),
21+
})
22+
);
4223

4324
export default useResetMutation;

frieren-front/src/hooks/useShutDownMutation.js

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,21 @@
44
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
55
* More info at: https://github.com/xchwarze/frieren
66
*/
7-
import { useQueryClient } from '@tanstack/react-query';
8-
import { useSetAtom } from 'jotai'
9-
import { useLocation } from 'wouter';
10-
import { toast } from 'react-toastify';
11-
12-
import authAtom from '@src/atoms/authAtom.js';
137
import { fetchPost } from '@src/services/fetchService.js';
148
import useAuthenticatedMutation from '@src/hooks/useAuthenticatedMutation.js';
159

1610
/**
17-
* Shut down the real hardware.
11+
* Sends a shutdown command to the hardware.
1812
*
19-
* @return {Function} The mutation function for user logout
13+
* @return {Object} The mutation object for hardware shutdown.
2014
*/
21-
const useShutDownMutation = () => {
22-
const queryClient = useQueryClient();
23-
const setAuth = useSetAtom(authAtom)
24-
const [, setLocation] = useLocation();
25-
26-
return useAuthenticatedMutation({
27-
mutationFn: () => fetchPost({
28-
module: 'header',
29-
action: 'shutDownHardware',
30-
}),
31-
onSuccess: () => {
32-
setAuth(false);
33-
setLocation('/');
34-
queryClient.clear();
35-
toast.success('Device is shutting down');
36-
},
37-
onError: () => {
38-
toast.error('Shutdown failed');
39-
}
40-
});
41-
};
15+
const useShutDownMutation = () => (
16+
useAuthenticatedMutation({
17+
mutationFn: () => fetchPost({
18+
module: 'header',
19+
action: 'shutDownHardware',
20+
}),
21+
})
22+
);
4223

4324
export default useShutDownMutation;

0 commit comments

Comments
 (0)