feat: add gateway setup wizard and fix test suite

New zt-gateway-setup script with subcommands for configuring this
router as a ZeroTier exit gateway: bridge, routing (tables 100/101),
DHCP, hotplug, and persistence. Exposes setup ubus method via backend
with ACL and LuCI panel in overview.

Also fixes two pre-existing test issues:
- overview.spec.ts: active gateway radio is intentionally enabled
  (users need to re-trigger switch for the already-selected gateway)
- drain.spec.ts: stubs drain status via page.route() since Docker
  has no real conntrack entries; fixed Playwright glob pattern bug
  where **/ubus* fails to match /ubus/?timestamp (regex needed)
This commit is contained in:
2026-07-13 08:48:53 +05:30
parent fbbddd2850
commit cd429291ef
9 changed files with 728 additions and 23 deletions

View File

@@ -29,15 +29,64 @@ test.describe('drain UI', () => {
// Change mode to graceful
await page.locator('.zt-mode-select').selectOption('graceful');
// Click "Switch to selected"
await page.locator('.cbi-button-positive').click();
// Stub status responses to report an active drain with remaining connections.
// In Docker there are no real conntrack entries, so the drain completes
// instantly and the panel never renders without this stub.
//
// LuCI sends ubus calls as JSON-RPC batches: [{method:"call", params:[..., "status", {}]}]
// and receives: [{result: [0, {gateways:[], drain:{...}}]}]
await page.route(/\/ubus/, async (route) => {
const postData = route.request().postData() || '';
if (!postData.includes('"status"')) {
return route.fallback();
}
// Wait for the drain panel to appear (may be brief if conntrack is unavailable)
const drainPanel = page.locator('.zt-drain-panel');
await expect(drainPanel).toBeVisible({ timeout: 45_000 });
await expect(drainPanel).toContainText('Graceful drain progress');
// Fabricate a status response with an active drain state.
// We avoid route.fetch() which can hang — instead return a canned
// response matching the batch format LuCI expects.
const gateways = [
{ region: 'amsterdam', label: 'Amsterdam (ocirosea641)', ip: '10.99.12.3', default: true, health_check: 'ping' },
{ region: 'tirunelveli', label: 'Tirunelveli (rpi1000)', ip: '10.99.12.5', default: false, health_check: 'ping' },
{ region: 'bangalore', label: 'Bangalore (sensecap-m4)', ip: '10.99.12.4', default: false, health_check: 'ping' }
];
const body = [{
jsonrpc: '2.0',
id: 1,
result: [0, {
active_gateway: 'amsterdam',
active_ip: '10.99.12.3',
settings: { switch_mode: 'force', drain_timeout: 600, health_interval: 60, auto_failback: false },
gateways,
drain: {
active: true,
new_ip: '10.99.12.5',
old_ip: '10.99.12.3',
remaining_connections: 42
}
}]
}];
// Panel references old and new IP addresses
await expect(drainPanel).toContainText(/\d+\.\d+\.\d+\.\d+/);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
});
});
try {
// Click "Switch to selected"
await page.locator('.cbi-button-positive').click();
// The next status poll (triggered by onSwitchClick's finally block)
// will return the stubbed drain state, causing the panel to render.
const drainPanel = page.locator('.zt-drain-panel');
await expect(drainPanel).toBeVisible({ timeout: 15_000 });
await expect(drainPanel).toContainText('Graceful drain progress');
// Panel references old and new IP addresses
await expect(drainPanel).toContainText(/\d+\.\d+\.\d+\.\d+/);
} finally {
await page.unroute(/\/ubus/);
}
});
});