diff --git a/core/frontend/src/components/ethernet/EthernetManager.vue b/core/frontend/src/components/ethernet/EthernetManager.vue index 7e8c204b84..07849c30fd 100644 --- a/core/frontend/src/components/ethernet/EthernetManager.vue +++ b/core/frontend/src/components/ethernet/EthernetManager.vue @@ -3,20 +3,20 @@ elevation="1" width="400" > - + - + - +
No ethernet interfaces available
diff --git a/core/frontend/src/components/ethernet/EthernetUpdater.vue b/core/frontend/src/components/ethernet/EthernetUpdater.vue index a31a1f494d..84c676cf8b 100644 --- a/core/frontend/src/components/ethernet/EthernetUpdater.vue +++ b/core/frontend/src/components/ethernet/EthernetUpdater.vue @@ -12,11 +12,17 @@ export default Vue.extend({ name: 'EthernetUpdater', data() { return { - fetch_available_interfaces_task: new OneMoreTime({ delay: 5000, disposeWith: this }), + fetch_available_interfaces_task: new OneMoreTime({ delay: 5000, disposeWith: this, autostart: false }), } }, - mounted() { - this.fetch_available_interfaces_task.setAction(this.fetchAvailableEthernetInterfaces) + async mounted() { + try { + await this.fetchAvailableEthernetInterfaces() + } finally { + ethernet.setUpdatingInterfaces(false) + this.fetch_available_interfaces_task.setAction(this.fetchAvailableEthernetInterfaces) + this.fetch_available_interfaces_task.start() + } }, methods: { async fetchAvailableEthernetInterfaces(): Promise { diff --git a/core/frontend/src/components/ethernet/InterfaceCard.vue b/core/frontend/src/components/ethernet/InterfaceCard.vue index 6a85ee1358..4c4622da75 100644 --- a/core/frontend/src/components/ethernet/InterfaceCard.vue +++ b/core/frontend/src/components/ethernet/InterfaceCard.vue @@ -319,16 +319,12 @@ export default Vue.extend({ await ethernet.deleteAddress({ interface_name: this.adapter.name, ip_address: ip }) }, async triggerForDynamicIP(): Promise { - ethernet.setUpdatingInterfaces(true) - await ethernet.triggerDynamicIP(this.adapter.name) }, openDHCPServerDialog(): void { this.show_dhcp_server_dialog = true }, async removeDHCPServer(): Promise { - ethernet.setUpdatingInterfaces(true) - await ethernet.RemoveDHCPServer(this.adapter.name) }, async fetchLeases(): Promise { diff --git a/core/frontend/src/store/ethernet.ts b/core/frontend/src/store/ethernet.ts index 9f067b2618..8be6b78032 100644 --- a/core/frontend/src/store/ethernet.ts +++ b/core/frontend/src/store/ethernet.ts @@ -32,7 +32,6 @@ class EthernetStore extends VuexModule { @Mutation setInterfaces(ethernet_interfaces: EthernetInterface[]): void { this.available_interfaces = ethernet_interfaces - this.updating_interfaces = false } @Action @@ -52,6 +51,10 @@ class EthernetStore extends VuexModule { notifier.pushBackError('ETHERNET_ADDRESS_CREATION_FAIL', error) throw error }) + .finally(async () => { + await this.context.dispatch('refreshInterfaces') + this.context.commit('setUpdatingInterfaces', false) + }) } @Action @@ -71,6 +74,10 @@ class EthernetStore extends VuexModule { notifier.pushError('ETHERNET_ADDRESS_DELETE_FAIL', error) throw error }) + .finally(async () => { + await this.context.dispatch('refreshInterfaces') + this.context.commit('setUpdatingInterfaces', false) + }) } @Action @@ -91,13 +98,16 @@ class EthernetStore extends VuexModule { notifier.pushBackError('DHCP_SERVER_ADD_FAIL', error) throw error }) - .finally(() => { + .finally(async () => { + await this.context.dispatch('refreshInterfaces') this.context.commit('setUpdatingInterfaces', false) }) } @Action async RemoveDHCPServer(interface_name: string): Promise { + this.context.commit('setUpdatingInterfaces', true) + await back_axios({ method: 'delete', url: `${this.API_URL}/dhcp`, @@ -110,6 +120,10 @@ class EthernetStore extends VuexModule { const message = `Could not remove DHCP server from interface '${interface_name}': ${error.message}.` notifier.pushError('DHCP_SERVER_REMOVE_FAIL', message) }) + .finally(async () => { + await this.context.dispatch('refreshInterfaces') + this.context.commit('setUpdatingInterfaces', false) + }) } @Action @@ -189,6 +203,16 @@ class EthernetStore extends VuexModule { }) } + @Action + async refreshInterfaces(): Promise { + try { + const response = await this.context.dispatch('getAvailableEthernetInterfaces') + this.context.commit('setInterfaces', response.data) + } catch { + // Errors are already pushed to the notifier by getAvailableEthernetInterfaces. + } + } + @Action async setInterfacesPriority(interfaces: { name: string, priority: number }[]): Promise { await back_axios({ @@ -213,6 +237,8 @@ class EthernetStore extends VuexModule { @Action async triggerDynamicIP(interface_name: string): Promise { + this.context.commit('setUpdatingInterfaces', true) + await back_axios({ method: 'post', url: `${this.API_URL}/dynamic_ip`, @@ -225,6 +251,10 @@ class EthernetStore extends VuexModule { const message = `Could not trigger for dynamic IP address on '${interface_name}': ${error.message}.` notifier.pushError('DYNAMIC_IP_TRIGGER_FAIL', message) }) + .finally(async () => { + await this.context.dispatch('refreshInterfaces') + this.context.commit('setUpdatingInterfaces', false) + }) } } diff --git a/core/libs/commonwealth/src/commonwealth/utils/decorators.py b/core/libs/commonwealth/src/commonwealth/utils/decorators.py index 45875c0a64..4ec511cd5c 100644 --- a/core/libs/commonwealth/src/commonwealth/utils/decorators.py +++ b/core/libs/commonwealth/src/commonwealth/utils/decorators.py @@ -9,6 +9,9 @@ def temporary_cache(timeout_seconds: float = 10) -> Callable[[F], F]: """Decorator that creates a cache for specific inputs with a configured timeout in seconds. + The wrapped function exposes an `invalidate()` attribute that drops every cached entry, + forcing the next call to re-execute the function. + Args: timeout_seconds (float, optional): Timeout to be used for cache invalidation. Defaults to 10. @@ -35,6 +38,11 @@ def wrapper(*args: Any) -> Any: cache[args] = function_return return function_return + def invalidate() -> None: + cache.clear() + last_sample_time.clear() + + wrapper.invalidate = invalidate # type: ignore[attr-defined] return wrapper # type: ignore return inner_function diff --git a/core/libs/commonwealth/src/commonwealth/utils/tests/test_decorators.py b/core/libs/commonwealth/src/commonwealth/utils/tests/test_decorators.py index 604bf84a13..c643256e61 100644 --- a/core/libs/commonwealth/src/commonwealth/utils/tests/test_decorators.py +++ b/core/libs/commonwealth/src/commonwealth/utils/tests/test_decorators.py @@ -24,3 +24,16 @@ def test_nested_settings_save_load() -> None: # Check if all cache values are invalid after waiting for a long time assert all(original_output[key] != cached_function(key) for key in inputs) + + +def test_temporary_cache_invalidate() -> None: + inputs = ["first", "second", "third"] + original_output = {key: cached_function(key) for key in inputs} + + # Cache is still warm: same call returns the same value + assert all(original_output[key] == cached_function(key) for key in inputs) + + # Force invalidation; subsequent calls must re-execute the function + cached_function.invalidate() # type: ignore[attr-defined] + + assert all(original_output[key] != cached_function(key) for key in inputs) diff --git a/core/services/cable_guy/api/manager.py b/core/services/cable_guy/api/manager.py index 71f073a414..64cc2d9e6d 100644 --- a/core/services/cable_guy/api/manager.py +++ b/core/services/cable_guy/api/manager.py @@ -312,6 +312,8 @@ def _update_interface_settings(self, interface_name: str, updated_interface: Net self._settings.content = [interface for interface in self._settings.content if interface.name != interface_name] self._settings.content.append(updated_interface) self._manager.save() + # OS state has just been mutated; drop the cached view so the next read returns fresh data. + self.get_ethernet_interfaces.invalidate() # type: ignore[attr-defined] def add_static_ip(self, interface_name: str, ip: str, mode: AddressMode = AddressMode.Unmanaged) -> None: """Set ip address for a specific interface and saves it to the settings file @@ -477,6 +479,7 @@ def get_interfaces(self, filter_wifi: bool = False, include_dhcp_markers: bool = return result + @temporary_cache(timeout_seconds=10) def get_ethernet_interfaces(self, include_dhcp_markers: bool = False) -> List[NetworkInterface]: """Get ethernet interfaces information diff --git a/core/services/cable_guy/main.py b/core/services/cable_guy/main.py index d1712077de..62e3d5f9de 100755 --- a/core/services/cable_guy/main.py +++ b/core/services/cable_guy/main.py @@ -36,7 +36,6 @@ @app.get("/ethernet", response_model=List[NetworkInterface], summary="Retrieve ethernet interfaces.") @version(1, 0) -@temporary_cache(timeout_seconds=10) def retrieve_ethernet_interfaces() -> Any: """REST API endpoint to retrieve the configured ethernet interfaces.""" return manager.get_ethernet_interfaces()