From 3f9e2a9fa86a54aec62463740e5d1baa7a1e420d Mon Sep 17 00:00:00 2001 From: Sam Freund Date: Wed, 7 Jan 2026 01:53:05 -0600 Subject: [PATCH] Force reload after restart and switch URL after IP change (#2278) ## Description Forces a reload after restarting PhotonVision, restarting the coprocessor, performing an offline update, or nuking the install. We wait until we are reconnected to the coprocessor to reload, this is accomplished by the addition of a status API endpoint. This is being implemented due to issues experienced when the webpage is not updated (particularly during offline updates). --- Using the same statusCheck, we also wait until a new IP is available, then change to it, after changing our static IP. --- closes #2169 closes #903 ## Meta Merge checklist: - [x] Pull Request title is [short, imperative summary](https://cbea.ms/git-commit/) of proposed changes - [x] The description documents the _what_ and _why_ - [ ] If this PR changes behavior or adds a feature, user documentation is updated - [ ] If this PR touches photon-serde, all messages have been regenerated and hashes have not changed unexpectedly - [ ] If this PR touches configuration, this is backwards compatible with settings back to v2025.3.2 - [ ] If this PR touches pipeline settings or anything related to data exchange, the frontend typing is updated - [ ] If this PR addresses a bug, a regression test for it is added --- .../components/settings/DeviceControlCard.vue | 22 +++-- .../settings/GlobalSettingsCard.vue | 91 +++++++++++-------- photon-client/src/lib/PhotonUtils.ts | 43 +++++++++ .../photonvision/server/RequestHandler.java | 5 + .../java/org/photonvision/server/Server.java | 2 + 5 files changed, 116 insertions(+), 47 deletions(-) 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);