Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/app/src/update/device/network/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ pub fn handle_network_form_update(
..
} = &model.network_form_state
{
// Validate that this update is for the adapter currently being edited
// This protects against hidden Shell components (like v-window-items in Vue)
// sending updates for non-active adapters
if adapter_name != &form_data.name {
// Silently ignore updates from non-active adapters
return crux_core::render::render();
}

let is_dirty = form_data != *original_data;

// Compute rollback modal flags
Expand Down Expand Up @@ -250,6 +258,55 @@ mod tests {
}
assert!(!model.network_form_dirty);
}

#[test]
fn ignores_updates_from_non_active_adapter() {
// Setup: editing eth0, but receive update for wlan0
let eth0_data = NetworkFormData {
name: "eth0".to_string(),
ip_address: "192.168.1.100".to_string(),
dhcp: false,
prefix_len: 24,
dns: vec!["8.8.8.8".to_string()],
gateways: vec!["192.168.1.1".to_string()],
};

let wlan0_data = NetworkFormData {
name: "wlan0".to_string(),
ip_address: "192.168.2.100".to_string(),
dhcp: false,
prefix_len: 24,
dns: vec!["8.8.8.8".to_string()],
gateways: vec!["192.168.2.1".to_string()],
};

let mut model = Model {
network_form_state: NetworkFormState::Editing {
adapter_name: "eth0".to_string(),
form_data: eth0_data.clone(),
original_data: eth0_data.clone(),
},
network_form_dirty: false,
..Default::default()
};

// Send update for wlan0 while eth0 is being edited
let _ =
handle_network_form_update(serde_json::to_string(&wlan0_data).unwrap(), &mut model);

// State should remain unchanged
if let NetworkFormState::Editing {
adapter_name,
form_data,
..
} = &model.network_form_state
{
assert_eq!(adapter_name, "eth0");
assert_eq!(form_data.ip_address, "192.168.1.100");
assert_eq!(form_data.name, "eth0");
}
assert!(!model.network_form_dirty);
}
}

mod rollback_modal_flags {
Expand Down
4 changes: 3 additions & 1 deletion src/app/src/update/device/network/verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@ mod tests {
use super::*;

#[test]
#[ignore]
fn tick_increments_attempt_counter() {
let mut model = Model {
network_change_state: NetworkChangeState::WaitingForNewIp {
Expand All @@ -210,9 +209,12 @@ mod tests {

let _ = handle_new_ip_check_tick(&mut model);

// Verify attempt counter was incremented
if let NetworkChangeState::WaitingForNewIp { attempt, .. } = model.network_change_state
{
assert_eq!(attempt, 1);
} else {
panic!("Expected WaitingForNewIp state");
}
}

Expand Down
11 changes: 9 additions & 2 deletions src/ui/src/components/network/DeviceNetworks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import NetworkSettings from "./NetworkSettings.vue"
import { useCore } from "../../composables/useCore"
import { useCoreInitialization } from "../../composables/useCoreInitialization"

const { viewModel, networkFormReset } = useCore()
const { viewModel, networkFormReset, networkFormStartEdit } = useCore()

useCoreInitialization()

Expand Down Expand Up @@ -38,6 +38,9 @@ watch(tab, (newTab, oldTab) => {
// Revert tab back to old tab
isReverting.value = true
tab.value = oldTab
} else if (newTab) {
// Tab change successful - notify Core to start editing this adapter
networkFormStartEdit(newTab as string)
}
})

Expand All @@ -54,6 +57,10 @@ const confirmTabChange = () => {

// Now switch to the pending tab
tab.value = pendingTab.value

// Start editing the new adapter
networkFormStartEdit(pendingTab.value)

pendingTab.value = null
}
showUnsavedChangesDialog.value = false
Expand All @@ -77,7 +84,7 @@ const cancelTabChange = () => {
:value="networkAdapter.name"></v-tab>
</v-tabs>
<v-window v-model="tab" class="w[20vw]" direction="vertical">
<v-window-item v-for="networkAdapter in networkStatus?.network_status" :value="networkAdapter.name">
<v-window-item v-for="networkAdapter in networkStatus?.network_status" :key="networkAdapter.name" :value="networkAdapter.name">
<NetworkSettings :networkAdapter="networkAdapter" :isCurrentConnection="isCurrentConnection(networkAdapter)" />
</v-window-item>
</v-window>
Expand Down
39 changes: 23 additions & 16 deletions src/ui/src/components/network/NetworkSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { DeviceNetwork } from "../../types"
import type { NetworkConfigRequest } from "../../composables/useCore"

const { showError } = useSnackbar()
const { viewModel, setNetworkConfig, networkFormReset, networkFormUpdate, networkFormStartEdit } = useCore()
const { viewModel, setNetworkConfig, networkFormReset, networkFormUpdate } = useCore()
const { copy } = useClipboard()
const { isValidIp: validateIp, parseNetmask } = useIPValidation()

Expand All @@ -28,10 +28,10 @@ const isSubmitting = ref(false)
const isSyncingFromWebSocket = ref(false)
const isStartingFreshEdit = ref(false)

// Initialize form editing state in Core when component mounts
// NOTE: NetworkFormStartEdit is now called by the parent DeviceNetworks.vue when tab changes
// This prevents all mounted components from calling it simultaneously
// Set flag to prevent the dirty flag watch from resetting form during initialization
isStartingFreshEdit.value = true
networkFormStartEdit(props.networkAdapter.name)
nextTick(() => {
nextTick(() => {
isStartingFreshEdit.value = false
Expand Down Expand Up @@ -64,6 +64,8 @@ const sendFormUpdateToCore = () => {
// Use flush: 'post' to ensure watcher runs after all DOM updates
watch([ipAddress, dns, gateways, addressAssignment, netmask], () => {
// Don't update dirty flag during submit or WebSocket sync
// Note: Core validates adapter_name matches the currently editing adapter
// This defends against hidden components (v-window) sending stale data
if (!isSubmitting.value && !isSyncingFromWebSocket.value) {
sendFormUpdateToCore()
}
Expand Down Expand Up @@ -100,25 +102,30 @@ watch(() => viewModel.network_form_dirty, (isDirty, wasDirty) => {
})

// Watch for prop changes from WebSocket updates and sync local state
// IMPORTANT: We watch the entire adapter object to ensure reactivity,
// but only reset form fields if not dirty. This allows props.networkAdapter.online
// to remain reactive even when the form is dirty.
watch(() => props.networkAdapter, (newAdapter) => {
if (!newAdapter) return

// Don't overwrite user's unsaved changes during submit or when user has made edits
if (isSubmitting.value || viewModel.network_form_dirty) {
return
}

// Set flag to prevent form watchers from firing during sync
isSyncingFromWebSocket.value = true
resetFormFields()
// Only reset form fields if user hasn't made unsaved changes
// But we still need to let this watcher run to maintain reactivity for
// non-form props like 'online' status
if (!isSubmitting.value && !viewModel.network_form_dirty) {
// Set flag to prevent form watchers from firing during sync
isSyncingFromWebSocket.value = true
resetFormFields()

// Clear flag after Vue finishes all reactive updates AND all post-flush watchers
// Need double nextTick: first for reactive updates, second for post-flush watchers
nextTick(() => {
// Clear flag after Vue finishes all reactive updates AND all post-flush watchers
// Need double nextTick: first for reactive updates, second for post-flush watchers
nextTick(() => {
isSyncingFromWebSocket.value = false
nextTick(() => {
isSyncingFromWebSocket.value = false
})
})
})
}
// Note: We don't return early when dirty - this allows Vue's reactivity
// system to track changes to props.networkAdapter.online and other non-form props
}, { deep: true })

const isDHCP = computed(() => addressAssignment.value === "dhcp")
Expand Down
Loading
Loading