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 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
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
4 changes: 2 additions & 2 deletions src/app/src/update/device/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ fn update_network_state_and_spinner(
if rollback_enabled {
"Network configuration is being applied. Your connection will be interrupted. \
Use your DHCP server or device console to find the new IP address. \
You must access the new address to cancel the automatic rollback."
You must access the new address and log in to cancel the automatic rollback."
} else {
"Network configuration has been applied. Your connection will be interrupted. \
Use your DHCP server or device console to find the new IP address."
}
} else if rollback_enabled {
"Network configuration is being applied. Click the button below to open the new address in a new tab. \
You must access the new address to cancel the automatic rollback."
You must access the new address and log in to cancel the automatic rollback."
} else {
"Network configuration has been applied. Your connection will be interrupted. \
Click the button below to navigate to the new address."
Expand Down
7 changes: 4 additions & 3 deletions src/ui/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Disabled: Network tests share a Centrifugo WebSocket channel, parallel execution causes interference */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Use single worker to prevent Centrifugo channel interference between test files */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
Expand Down
28 changes: 25 additions & 3 deletions src/ui/src/components/network/NetworkSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,20 @@ const gateways = ref(props.networkAdapter?.ipv4?.gateways?.join("\n") || "")
const addressAssignment = ref(props.networkAdapter?.ipv4?.addrs[0]?.dhcp ? "dhcp" : "static")
const netmask = ref(props.networkAdapter?.ipv4?.addrs[0]?.prefix_len || 24)

// State flags - declared early since they're used in watchers and initialization
const isSubmitting = ref(false)
const isSyncingFromWebSocket = ref(false)
const isStartingFreshEdit = ref(false)

// Initialize form editing state in Core when component mounts
// 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
})
})

// Helper to reset form fields to match current adapter data
const resetFormFields = () => {
Expand Down Expand Up @@ -57,7 +69,19 @@ watch([ipAddress, dns, gateways, addressAssignment, netmask], () => {
}, { flush: 'post' })

// Watch for form reset from Core (when dirty flag clears for this adapter)
// This should ONLY reset the form when the user explicitly clicks "Reset",
// NOT when starting a fresh edit or after completing a submit
watch(() => viewModel.network_form_dirty, (isDirty, wasDirty) => {
// Skip reset if we're starting a fresh edit (the form is being initialized)
if (isStartingFreshEdit.value) {
return
}

// Skip reset during submit - the dirty flag clears during submit, not because user reset
if (isSubmitting.value) {
return
}

const isEditingThisAdapter = viewModel.network_form_state?.type === 'editing' &&
(viewModel.network_form_state as any).adapter_name === props.networkAdapter.name

Expand Down Expand Up @@ -97,8 +121,6 @@ watch(() => props.networkAdapter, (newAdapter) => {
}, { deep: true })

const isDHCP = computed(() => addressAssignment.value === "dhcp")
const isSubmitting = ref(false)
const isSyncingFromWebSocket = ref(false)
const isServerAddr = computed(() => props.networkAdapter?.ipv4?.addrs[0]?.addr === location.hostname)
const ipChanged = computed(() => props.networkAdapter?.ipv4?.addrs[0]?.addr !== ipAddress.value)
const dhcpChanged = computed(() => props.networkAdapter?.ipv4?.addrs[0]?.dhcp !== isDHCP.value)
Expand Down Expand Up @@ -216,7 +238,7 @@ const cancelRollbackModal = () => {
<div>
<strong>Enable automatic rollback (recommended)</strong>
<div class="text-caption text-medium-emphasis">
If you can't reach the new IP within 90 seconds, the device will automatically
If you can't reach the new IP and log in within 90 seconds, the device will automatically
restore the previous configuration.
</div>
</div>
Expand Down
295 changes: 295 additions & 0 deletions src/ui/tests/fixtures/network-test-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { Page } from '@playwright/test';
import { publishToCentrifugo } from './centrifugo';

export interface DeviceNetwork {
name: string;
mac: string;
online: boolean;
ipv4?: {
addrs: Array<{ addr: string; dhcp: boolean; prefix_len: number }>;
dns: string[];
gateways: string[];
};
}

export interface NetworkTestHarnessConfig {
rollbackTimeoutSeconds?: number;
enableHealthcheckPolling?: boolean;
healthcheckSuccessAfter?: number; // ms
healthcheckAlwaysFails?: boolean;
}

export interface SetNetworkConfigResponse {
rollbackTimeoutSeconds: number;
uiPort: number;
rollbackEnabled: boolean;
}

export class NetworkTestHarness {
private rollbackEnabled: boolean = false;
private rollbackDeadline: number | null = null;
private currentIp: string = '192.168.1.100';
private newIp: string | null = null;
private healthcheckCallCount: number = 0;
private healthcheckConfig: NetworkTestHarnessConfig = {};
private networkRollbackOccurred: boolean = false;
private lastNetworkConfig: DeviceNetwork[] = [];

/**
* Mock the /network endpoint with configurable response
*/
async mockNetworkConfig(page: Page, config: NetworkTestHarnessConfig = {}): Promise<void> {
await page.route('**/network', async (route) => {
if (route.request().method() === 'POST') {
const requestBody = route.request().postDataJSON();

// Track rollback state
this.rollbackEnabled = requestBody.enableRollback === true;

// If rollback enabled, set deadline
if (this.rollbackEnabled) {
const timeoutSeconds = config.rollbackTimeoutSeconds || 90;
this.rollbackDeadline = Date.now() + (timeoutSeconds * 1000);
}

// Track IP change
if (requestBody.ip && requestBody.ip !== this.currentIp) {
this.newIp = requestBody.ip;
}

// Reset healthcheck counter
this.healthcheckCallCount = 0;
this.healthcheckConfig = config;

// Send success response
const response: SetNetworkConfigResponse = {
rollbackTimeoutSeconds: config.rollbackTimeoutSeconds || 90,
uiPort: 5173,
rollbackEnabled: this.rollbackEnabled,
};

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
} else {
await route.continue();
}
});
}

/**
* Mock the /network endpoint to return an error
*/
async mockNetworkConfigError(page: Page, statusCode: number = 500, errorMessage: string = 'Internal server error'): Promise<void> {
await page.route('**/network', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: statusCode,
contentType: 'application/json',
body: JSON.stringify({ error: errorMessage }),
});
} else {
await route.continue();
}
});
}

/**
* Mock the /healthcheck endpoint with configurable responses
*/
async mockHealthcheck(page: Page, config: NetworkTestHarnessConfig = {}): Promise<void> {
this.healthcheckConfig = config;

await page.route('**/healthcheck', async (route) => {
this.healthcheckCallCount++;

// Determine if healthcheck should succeed
let healthcheckSucceeds = false;

if (config.healthcheckAlwaysFails) {
healthcheckSucceeds = false;
} else if (config.healthcheckSuccessAfter !== undefined) {
// Calculate elapsed time since first healthcheck
const elapsedMs = (this.healthcheckCallCount - 1) * 2000; // Assuming 2s poll interval
healthcheckSucceeds = elapsedMs >= config.healthcheckSuccessAfter;
} else {
// Default: succeed after a few attempts
healthcheckSucceeds = this.healthcheckCallCount >= 3;
}

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
version_info: {
required: '>=0.39.0',
current: '0.40.0',
mismatch: false,
},
update_validation_status: {
status: 'valid',
},
network_rollback_occurred: this.networkRollbackOccurred,
}),
});
});
}

/**
* Mock /ack-rollback endpoint
*/
async mockAckRollback(page: Page): Promise<void> {
await page.route('**/ack-rollback', async (route) => {
if (route.request().method() === 'POST') {
this.networkRollbackOccurred = false;
await route.fulfill({
status: 200,
});
}
});
}

/**
* Publish network status via Centrifugo
*/
async publishNetworkStatus(adapters: DeviceNetwork[]): Promise<void> {
this.lastNetworkConfig = adapters;
await publishToCentrifugo('NetworkStatusV1', {
network_status: adapters,
});
}

/**
* Simulate automatic rollback after timeout
* This sets the rollback occurred flag and reverts network status
*/
async simulateRollbackTimeout(): Promise<void> {
if (!this.rollbackEnabled) {
throw new Error('Cannot simulate rollback timeout: rollback was not enabled');
}

this.networkRollbackOccurred = true;
this.rollbackEnabled = false;
this.rollbackDeadline = null;

// Revert IP to original
if (this.newIp) {
this.newIp = null;
}

// Publish reverted network status
const revertedAdapters = this.lastNetworkConfig.map(adapter => ({
...adapter,
ipv4: adapter.ipv4 ? {
...adapter.ipv4,
addrs: adapter.ipv4.addrs.map(addr => ({
...addr,
addr: this.currentIp,
})),
} : undefined,
}));

await this.publishNetworkStatus(revertedAdapters);
}

/**
* Simulate successful connection to new IP (cancels rollback)
*/
async simulateNewIpReachable(): Promise<void> {
if (!this.rollbackEnabled) {
throw new Error('Cannot simulate new IP reachable: rollback was not enabled');
}

// Cancel rollback
this.rollbackEnabled = false;
this.rollbackDeadline = null;

// Update current IP to new IP
if (this.newIp) {
this.currentIp = this.newIp;
this.newIp = null;
}
}

/**
* Create a standard network adapter with customizable config
*/
createAdapter(name: string, config: Partial<DeviceNetwork> = {}): DeviceNetwork {
return {
name,
mac: config.mac || '00:11:22:33:44:55',
online: config.online !== undefined ? config.online : true,
ipv4: config.ipv4 || {
addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }],
dns: ['8.8.8.8'],
gateways: ['192.168.1.1'],
},
};
}

/**
* Create multiple adapters for testing multi-adapter scenarios
*/
createMultipleAdapters(count: number): DeviceNetwork[] {
const names = ['eth0', 'eth1', 'wlan0', 'eth2', 'wlan1'];
const macs = [
'00:11:22:33:44:55',
'00:11:22:33:44:56',
'00:11:22:33:44:57',
'00:11:22:33:44:58',
'00:11:22:33:44:59',
];

return Array.from({ length: Math.min(count, 5) }, (_, i) => ({
name: names[i],
mac: macs[i],
online: true,
ipv4: {
addrs: [{ addr: `192.168.1.${100 + i}`, dhcp: false, prefix_len: 24 }],
dns: ['8.8.8.8'],
gateways: ['192.168.1.1'],
},
}));
}

/**
* Set the rollback occurred flag (for testing rollback notification)
*/
setRollbackOccurred(occurred: boolean): void {
this.networkRollbackOccurred = occurred;
}

/**
* Get current rollback state
*/
getRollbackState(): { enabled: boolean; deadline: number | null; occurred: boolean } {
return {
enabled: this.rollbackEnabled,
deadline: this.rollbackDeadline,
occurred: this.networkRollbackOccurred,
};
}

/**
* Get healthcheck call count
*/
getHealthcheckCallCount(): number {
return this.healthcheckCallCount;
}

/**
* Reset harness state (for test cleanup)
*/
reset(): void {
this.rollbackEnabled = false;
this.rollbackDeadline = null;
this.currentIp = '192.168.1.100';
this.newIp = null;
this.healthcheckCallCount = 0;
this.healthcheckConfig = {};
this.networkRollbackOccurred = false;
this.lastNetworkConfig = [];
}
}
Loading
Loading