diff --git a/photon-client/src/components/settings/DeviceControlCard.vue b/photon-client/src/components/settings/DeviceControlCard.vue index 4c03121e5..9f5f268c8 100644 --- a/photon-client/src/components/settings/DeviceControlCard.vue +++ b/photon-client/src/components/settings/DeviceControlCard.vue @@ -4,15 +4,17 @@ import { useStateStore } from "@/stores/StateStore"; import PvSelect from "@/components/common/pv-select.vue"; import PvDeleteModal from "@/components/common/pv-delete-modal.vue"; import { useTheme } from "vuetify"; -import { axiosPost } from "@/lib/PhotonUtils"; +import { axiosPost, forceReloadPage } from "@/lib/PhotonUtils"; const theme = useTheme(); -const restartProgram = () => { - axiosPost("/utils/restartProgram", "restart PhotonVision"); +const restartProgram = async () => { + await axiosPost("/utils/restartProgram", "restart PhotonVision"); + forceReloadPage(); }; -const restartDevice = () => { - axiosPost("/utils/restartDevice", "restart the device"); +const restartDevice = async () => { + await axiosPost("/utils/restartDevice", "restart the device"); + forceReloadPage(); }; const address = inject("backendHost"); @@ -21,7 +23,7 @@ const offlineUpdate = ref(); const openOfflineUpdatePrompt = () => { offlineUpdate.value.click(); }; -const handleOfflineUpdate = () => { +const handleOfflineUpdate = async () => { const files = offlineUpdate.value.files; if (files.length === 0) return; @@ -34,7 +36,7 @@ const handleOfflineUpdate = () => { timeout: -1 }); - axiosPost("/utils/offlineUpdate", "upload new software", formData, { + await axiosPost("/utils/offlineUpdate", "upload new software", formData, { headers: { "Content-Type": "multipart/form-data" }, onUploadProgress: ({ progress }) => { const uploadPercentage = (progress || 0) * 100.0; @@ -55,6 +57,7 @@ const handleOfflineUpdate = () => { } } }); + forceReloadPage(); }; const exportLogFile = ref(); @@ -113,8 +116,9 @@ const handleSettingsImport = () => { }; const showFactoryReset = ref(false); -const nukePhotonConfigDirectory = () => { - axiosPost("/utils/nukeConfigDirectory", "delete the config directory"); +const nukePhotonConfigDirectory = async () => { + await axiosPost("/utils/nukeConfigDirectory", "delete the config directory"); + forceReloadPage(); }; diff --git a/photon-client/src/components/settings/GlobalSettingsCard.vue b/photon-client/src/components/settings/GlobalSettingsCard.vue index bfbd6844d..6181de483 100644 --- a/photon-client/src/components/settings/GlobalSettingsCard.vue +++ b/photon-client/src/components/settings/GlobalSettingsCard.vue @@ -9,6 +9,7 @@ import { type ConfigurableNetworkSettings, NetworkConnectionType } from "@/types import { useStateStore } from "@/stores/StateStore"; import { useTheme } from "vuetify"; import { getThemeColor, setThemeColor, resetTheme } from "@/lib/ThemeManager"; +import { statusCheck } from "@/lib/PhotonUtils"; const theme = useTheme(); @@ -80,9 +81,7 @@ const settingsHaveChanged = (): boolean => { ); }; -const saveGeneralSettings = () => { - const changingStaticIp = useSettingsStore().network.connectionType === NetworkConnectionType.Static; - +const saveGeneralSettings = async () => { // replace undefined members with empty strings for backend const payload = { connectionType: tempSettingsStruct.value.connectionType, @@ -97,42 +96,58 @@ const saveGeneralSettings = () => { staticIp: tempSettingsStruct.value.staticIp }; - useSettingsStore() - .updateGeneralSettings(payload) - .then((response) => { - useStateStore().showSnackbarMessage({ message: response.data.text || response.data, color: "success" }); + const changingStaticIP = + useSettingsStore().network.connectionType === NetworkConnectionType.Static && + tempSettingsStruct.value.staticIp !== useSettingsStore().network.staticIp; - // Update the local settings cause the backend checked their validity. Assign is to deref value - useSettingsStore().network = { ...useSettingsStore().network, ...Object.assign({}, tempSettingsStruct.value) }; - }) - .catch((error) => { - resetTempSettingsStruct(); - if (error.response) { - if (error.status === 504 || changingStaticIp) { - useStateStore().showSnackbarMessage({ - color: "error", - message: `Connection lost! Try the new static IP at ${useSettingsStore().network.staticIp}:5800 or ${ - useSettingsStore().network.hostname - }:5800?` - }); - } else { - useStateStore().showSnackbarMessage({ - color: "error", - message: error.response.data.text || error.response.data - }); - } - } else if (error.request) { - useStateStore().showSnackbarMessage({ - color: "error", - message: "Error while trying to process the request! The backend didn't respond." - }); - } else { - useStateStore().showSnackbarMessage({ - color: "error", - message: "An error occurred while trying to process the request." - }); - } - }); + try { + const response = await useSettingsStore().updateGeneralSettings(payload); + useStateStore().showSnackbarMessage({ message: response.data.text || response.data, color: "success" }); + + // Update the local settings cause the backend checked their validity. Assign is to deref value + useSettingsStore().network = { ...useSettingsStore().network, ...Object.assign({}, tempSettingsStruct.value) }; + } catch (error: any) { + resetTempSettingsStruct(); + if (error.response) { + useStateStore().showSnackbarMessage({ + color: "error", + message: error.response.data.text || error.response.data + }); + } else if (error.request) { + useStateStore().showSnackbarMessage({ + color: "error", + message: "Error while trying to process the request! The backend didn't respond." + }); + } else { + useStateStore().showSnackbarMessage({ + color: "error", + message: "An error occurred while trying to process the request." + }); + } + return; + } + + if (changingStaticIP) { + const status = await statusCheck(5000, tempSettingsStruct.value.staticIp); + + if (!status) { + useStateStore().showSnackbarMessage({ + message: + "Warning: Unable to verify new static IP address! You may need to manually navigate to the new address: http://" + + tempSettingsStruct.value.staticIp + + ":5800", + color: "warning" + }); + return; + } + + // Keep current hash route (e.g., #/settings) + const hash = window.location.hash || ""; + const url = `http://${tempSettingsStruct.value.staticIp}:5800/${hash}`; + setTimeout(() => { + window.location.href = url; + }, 1000); + } }; const currentNetworkInterfaceIndex = computed({ diff --git a/photon-client/src/lib/PhotonUtils.ts b/photon-client/src/lib/PhotonUtils.ts index 83dee0433..805f4731c 100644 --- a/photon-client/src/lib/PhotonUtils.ts +++ b/photon-client/src/lib/PhotonUtils.ts @@ -6,6 +6,49 @@ export const resolutionsAreEqual = (a: Resolution, b: Resolution) => { return a.height === b.height && a.width === b.width; }; +/** + * Checks the status of the backend by polling the "/status" endpoint. + * + * This function will repeatedly attempt to send a GET request to the backend + * until a successful response is received or the specified timeout is reached. + * + * @param timeout - The maximum time in milliseconds to wait for a successful response. + * @param ip - Optional IP address of the backend server. If not provided, the default endpoint is used. This is meant for the case where the backend is running on a different IP than the frontend. + * @returns A promise that resolves to a boolean indicating whether the backend is responsive (true) or not (false). + */ +export const statusCheck = async (timeout: number, ip?: string): Promise => { + // Poll the backend until it's responsive or we hit the timeout + let pollLimit = Math.floor(timeout / 100); + while (pollLimit > 0) { + try { + pollLimit--; + await axios.get(ip ? `http://${ip}/status` : "/status"); + return true; + } catch { + // Backend not ready yet, wait and retry + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return false; +}; + +/** + * Forces a page reload after a brief delay and a status check. + */ +export const forceReloadPage = async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + useStateStore().showSnackbarMessage({ + message: "Reloading the page to apply changes...", + color: "success" + }); + + await statusCheck(20000); + + window.location.reload(); +}; + export const getResolutionString = (resolution: Resolution): string => `${resolution.width}x${resolution.height}`; export const parseJsonFile = async >(file: File): Promise => { diff --git a/photon-server/src/main/java/org/photonvision/server/RequestHandler.java b/photon-server/src/main/java/org/photonvision/server/RequestHandler.java index 688b02b15..9f2f9cdf9 100644 --- a/photon-server/src/main/java/org/photonvision/server/RequestHandler.java +++ b/photon-server/src/main/java/org/photonvision/server/RequestHandler.java @@ -75,6 +75,11 @@ public class RequestHandler { private static boolean testMode = false; + public static void onStatusRequest(Context ctx) { + ctx.status(200); + ctx.result("not dead yet"); + } + public static void setTestMode(boolean isTestMode) { testMode = isTestMode; } diff --git a/photon-server/src/main/java/org/photonvision/server/Server.java b/photon-server/src/main/java/org/photonvision/server/Server.java index 1481a32e5..573592a18 100644 --- a/photon-server/src/main/java/org/photonvision/server/Server.java +++ b/photon-server/src/main/java/org/photonvision/server/Server.java @@ -117,6 +117,8 @@ public class Server { }); /* API Events */ + app.get("/api/status", RequestHandler::onStatusRequest); + // Settings app.post("/api/settings", RequestHandler::onSettingsImportRequest); app.get("/api/settings/photonvision_config.zip", RequestHandler::onSettingsExportRequest);