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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The confirmation dialog appears when you:
3. If enabled:
- **For static IP changes**: You have 90 seconds to access the device at the new IP address. An overlay with a countdown timer will guide you to the new address. You must log in at the new IP address to confirm the change works.
- **For DHCP changes**: You have 90 seconds to find and access the new DHCP-assigned IP (check your DHCP server or device console). The overlay will show a countdown.
- If you don't access the new address and log in within 90 seconds, the device automatically restores the previous network configuration
- If you don't access the new address and log in within 90 seconds, the device automatically restores the previous network configuration. The browser will attempt to reconnect to the original address.
4. If disabled:
- Changes are applied immediately without automatic rollback protection
- **For static IP changes**: An overlay appears with a button to navigate to the new IP address
Expand Down
7 changes: 4 additions & 3 deletions scripts/run-e2e-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,14 @@ fi

# Start Vite preview server (serves production build)
echo "🚀 Starting Vite preview server..."
export VITE_HTTPS=true
bun run preview --port 5173 > /tmp/vite.log 2>&1 &
FRONTEND_PID=$!

# Wait for preview server
echo "⏳ Waiting for preview server..."
for i in {1..30}; do
if curl -s http://localhost:5173 > /dev/null; then
if curl -k -s https://localhost:5173 > /dev/null; then
echo "✅ Preview server is ready!"
break
fi
Expand Down Expand Up @@ -154,10 +155,10 @@ echo "📦 Ensuring Playwright browsers are installed..."
npx playwright install chromium

# BASE_URL is set for playwright.config.ts
export BASE_URL="http://localhost:5173"
export BASE_URL="https://localhost:5173"

# Run tests
npx playwright test "$@"
npx playwright test --reporter=list "$@"

TEST_EXIT_CODE=$?

Expand Down
6 changes: 3 additions & 3 deletions src/app/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ macro_rules! unauth_post {
.build()
.then_send(|result| {
let event_result = $crate::process_status_response($action, result);
$crate::events::Event::$domain(
$crate::events::$domain_event::$response_event(event_result),
)
$crate::events::Event::$domain($crate::events::$domain_event::$response_event(
event_result,
))
}),
]);
cmd
Expand Down
6 changes: 6 additions & 0 deletions src/app/src/types/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ impl OverlaySpinnerState {
self.timed_out = true;
}

/// Reset timed out state (back to loading/spinning)
pub fn set_loading(&mut self) {
self.timed_out = false;
self.countdown_seconds = None;
}

/// Show the overlay spinner
pub fn show(&mut self) {
self.overlay = true;
Expand Down
78 changes: 77 additions & 1 deletion src/app/src/types/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,33 +145,109 @@ impl NetworkFormState {
}
}

/// State of network IP change after configuration
/// State machine for network IP change after configuration.
///
/// This state machine tracks the progress of network configuration changes
/// that affect the device's IP address, including automatic rollback handling.
///
/// # State Machine Diagram
///
/// ```text
/// ┌─────────────────────────────────────────────────────────┐
/// │ START │
/// └────────────────────────┬────────────────────────────────┘
/// │
/// ▼
/// ┌────────────────┐
/// ┌───────────────│ Idle │───────────────┐
/// │ └────────────────┘ │
/// │ │ │
/// │ User applies │ │
/// │ network config │ │
/// │ ▼ │
/// │ ┌────────────────┐ │
/// │ │ ApplyingConfig │ │
/// │ └────────────────┘ │
/// │ │ │
/// │ Backend responds │ │
/// │ successfully │ │
/// │ ▼ │
/// │ ┌─────────────────┐ │
/// │ │ WaitingForNewIp │ │
/// │ └─────────────────┘ │
/// │ │ │ │
/// │ Healthcheck│ │Timeout expires │
/// │ succeeds │ │(rollback enabled)│
/// │ ▼ ▼ │
/// │ ┌────────────────┐ ┌─────────────────┐ │
/// │ │ NewIpReachable │ │ WaitingForOldIp │ │
/// │ └────────────────┘ └─────────────────┘ │
/// │ │ │ │
/// │ Redirect to │ Healthcheck │ │
/// │ new IP │ on old IP │ │
/// │ │ │ succeeds │ │
/// │ │ │ │ │ │
/// │ ▼ │ ▼ │ │
/// │ ┌───────────┴───────────┐ │ │
/// └───│ SUCCESS │◄───┘ │
/// └───────────────────────┘ │
/// │
/// ┌─────────────────────────────────────────────────┘
/// │ Timeout expires (rollback disabled)
/// ▼
/// ┌────────────────┐
/// │ NewIpTimeout │ (Shows manual navigation message)
/// └────────────────┘
/// ```
///
/// # State Descriptions
///
/// - **Idle**: No network change in progress
/// - **ApplyingConfig**: Configuration request sent to backend, waiting for response
/// - **WaitingForNewIp**: Polling new IP to verify reachability before rollback timeout
/// - **NewIpReachable**: New IP confirmed reachable, will redirect browser
/// - **NewIpTimeout**: Timeout expired without rollback enabled, show manual nav message
/// - **WaitingForOldIp**: Rollback assumed, now polling old IP to verify device is back
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NetworkChangeState {
/// No network change in progress
#[default]
Idle,
/// Configuration request sent to backend, waiting for response
ApplyingConfig {
is_server_addr: bool,
ip_changed: bool,
new_ip: String,
old_ip: String,
switching_to_dhcp: bool,
},
/// Polling new IP to verify reachability before rollback timeout
WaitingForNewIp {
new_ip: String,
old_ip: String,
attempt: u32,
rollback_timeout_seconds: u64,
ui_port: u16,
switching_to_dhcp: bool,
},
/// New IP confirmed reachable, browser will redirect
NewIpReachable {
new_ip: String,
ui_port: u16,
},
/// Timeout expired without confirming new IP (rollback disabled case)
NewIpTimeout {
new_ip: String,
old_ip: String,
ui_port: u16,
switching_to_dhcp: bool,
},
/// Rollback assumed complete, polling old IP to verify device is accessible
WaitingForOldIp {
old_ip: String,
ui_port: u16,
attempt: u32,
},
}

Expand Down
12 changes: 3 additions & 9 deletions src/app/src/update/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,7 @@ mod tests {
..Default::default()
};

let _ = app.update(
Event::Auth(AuthEvent::LogoutResponse(Ok(()))),
&mut model,
);
let _ = app.update(Event::Auth(AuthEvent::LogoutResponse(Ok(()))), &mut model);

assert!(!model.is_authenticated);
assert!(model.auth_token.is_none());
Expand Down Expand Up @@ -282,7 +279,7 @@ mod tests {

let _ = app.update(
Event::Auth(AuthEvent::SetPasswordResponse(Err(
"Password too weak".into(),
"Password too weak".into()
))),
&mut model,
);
Expand Down Expand Up @@ -376,10 +373,7 @@ mod tests {
let app = AppTester::<App>::default();
let mut model = Model::default();

let _ = app.update(
Event::Auth(AuthEvent::CheckRequiresPasswordSet),
&mut model,
);
let _ = app.update(Event::Auth(AuthEvent::CheckRequiresPasswordSet), &mut model);

assert!(model.is_loading);
}
Expand Down
14 changes: 11 additions & 3 deletions src/app/src/update/device/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ mod tests {
);

assert!(!model.is_loading);
assert_eq!(model.device_operation_state, DeviceOperationState::Rebooting);
assert_eq!(
model.device_operation_state,
DeviceOperationState::Rebooting
);
assert_eq!(model.success_message, Some("Reboot initiated".into()));
assert!(model.overlay_spinner.is_visible());
}
Expand All @@ -231,7 +234,10 @@ mod tests {
);

assert!(!model.is_loading);
assert_eq!(model.device_operation_state, DeviceOperationState::Rebooting);
assert_eq!(
model.device_operation_state,
DeviceOperationState::Rebooting
);
assert_eq!(
model.success_message,
Some("Reboot initiated (connection lost)".into())
Expand Down Expand Up @@ -450,7 +456,9 @@ mod tests {
};

let _ = app.update(
Event::Device(DeviceEvent::LoadUpdateResponse(Err("File not found".into()))),
Event::Device(DeviceEvent::LoadUpdateResponse(
Err("File not found".into()),
)),
&mut model,
);

Expand Down
Loading
Loading