Implements real-time wireless client monitoring with historical tracking, LiveView UI, proactive alerting, and cross-browser e2e tests. Phase 1: Historical Tracking - Add TimescaleDB hypertable for wireless_client_readings - Batch insert client metrics every 60 seconds from DevicePollerWorker - 90-day retention with compression after 7 days - Continuous aggregates for hourly (1 year) and daily (5 years) rollups Phase 2: LiveView UI - Add wireless tab to device detail page - Real-time client list with PubSub updates - Signal strength and SNR badges with 5-level thresholds - Display MAC, IP, subscriber, TX/RX rates, distance, uptime - Subscriber matching via device_subscriber_links - Empty state handling Phase 3: Proactive Alerting - WirelessInsightWorker runs every 5 minutes via Oban cron - 4 insight types with auto-resolution: * wireless_signal_weak: < -75 dBm (warning), < -85 dBm (critical) * wireless_snr_low: < 15 dB (warning), < 10 dB (critical) * wireless_ap_overloaded: > 50 clients (warning), > 75 clients (critical) * wireless_client_missing: expected subscribers not connecting - Hysteresis thresholds prevent alert flapping - Multi-organization isolation with proper deduplication Code Quality: - Refactored reload_current_tab_data to reduce cyclomatic complexity - Combined double Enum.filter into single pass for efficiency - Fixed length/1 comparison to use empty list check - All Credo checks passing Testing: - 28 unit tests (ExUnit) - 100% passing - 15 e2e tests (Playwright) - 100% passing across chromium/firefox/webkit - Total: 73 tests, all passing Files changed: - lib/towerops/workers/wireless_insight_worker.ex (NEW) - lib/towerops_web/live/device_live/show.ex (wireless tab + refactoring) - lib/towerops_web/live/device_live/show.html.heex (wireless template) - lib/towerops/snmp.ex (5 new query functions) - lib/towerops/gaiia.ex (list_missing_subscribers) - lib/towerops/preseem/insight.ex (5 new insight types) - config/runtime.exs (Oban cron schedule) - test/support/fixtures/snmp_fixtures.ex (NEW) - test/towerops/workers/wireless_insight_worker_test.exs (NEW) - test/towerops_web/live/device_live/show_test.exs (9 new tests) - e2e/tests/wireless-clients.spec.ts (NEW - 15 cross-browser tests)
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('Wireless Clients', () => {
|
|
test('can navigate to wireless tab from device page', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
// Navigate to first device
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for Wireless tab
|
|
const wirelessTab = page.locator(
|
|
'a[href*="tab=wireless"], button:has-text("Wireless"), a:has-text("Wireless")'
|
|
).first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify URL includes wireless tab
|
|
await expect(page).toHaveURL(/tab=wireless/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('shows wireless tab badge with client count', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for Wireless tab with badge
|
|
const wirelessTab = page.locator('a:has-text("Wireless")').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
// Check if badge/count is visible (could be 0 or more)
|
|
const badge = wirelessTab.locator('span');
|
|
if (await badge.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
// Badge should contain a number
|
|
const badgeText = await badge.textContent();
|
|
expect(badgeText).toMatch(/\d+/);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can view wireless clients list', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for wireless clients table or list
|
|
const clientsTable = page.locator('table, [role="table"]');
|
|
|
|
if (await clientsTable.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
// Table should have headers
|
|
const macHeader = page.locator('th:has-text("MAC"), th:has-text("MAC Address")').first();
|
|
if (await macHeader.isVisible().catch(() => false)) {
|
|
await expect(macHeader).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('shows signal strength badges', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for signal strength indicators
|
|
// Signal values are typically shown as "XX dBm"
|
|
const signalBadge = page.locator(':has-text("dBm")').first();
|
|
|
|
if (await signalBadge.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const signalText = await signalBadge.textContent();
|
|
expect(signalText).toMatch(/-?\d+\s*dBm/);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('shows SNR badges', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for SNR indicators
|
|
// SNR values are typically shown as "XX dB"
|
|
const snrBadge = page.locator(':has-text(" dB"):not(:has-text("dBm"))').first();
|
|
|
|
if (await snrBadge.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const snrText = await snrBadge.textContent();
|
|
expect(snrText).toMatch(/\d+\s*dB/);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('displays client MAC addresses', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for MAC address pattern (XX:XX:XX:XX:XX:XX)
|
|
const macAddress = page.locator('text=/[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}/i').first();
|
|
|
|
if (await macAddress.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(macAddress).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('displays client IP addresses', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for IP address pattern
|
|
const ipAddress = page.locator('text=/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/').first();
|
|
|
|
if (await ipAddress.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(ipAddress).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('shows empty state when no clients connected', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Check for empty state message
|
|
const emptyState = page.locator(
|
|
':has-text("No wireless clients"), :has-text("no clients"), :has-text("empty")'
|
|
).first();
|
|
|
|
// Either show client list or empty state (both are valid)
|
|
const clientsTable = page.locator('table tbody tr');
|
|
const hasClients = await clientsTable.count() > 0;
|
|
|
|
if (!hasClients) {
|
|
// If no clients, should show empty state
|
|
if (await emptyState.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(emptyState).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('displays TX/RX rates', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for rate indicators (Mbps/Kbps)
|
|
const rateIndicator = page.locator(':has-text("Mbps"), :has-text("Kbps")').first();
|
|
|
|
if (await rateIndicator.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(rateIndicator).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('displays client uptime', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for uptime indicators (hour/minute/second/day/week)
|
|
const uptimeIndicator = page.locator(
|
|
':has-text(" hour"), :has-text(" minute"), :has-text(" day"), :has-text(" week")'
|
|
).first();
|
|
|
|
if (await uptimeIndicator.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(uptimeIndicator).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('shows table headers for client information', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Check for key table headers
|
|
const headers = [
|
|
'MAC',
|
|
'IP',
|
|
'Signal',
|
|
'SNR',
|
|
];
|
|
|
|
for (const headerText of headers) {
|
|
const header = page.locator(`th:has-text("${headerText}")`).first();
|
|
if (await header.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
await expect(header).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can access wireless tab directly via URL', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
// Get first device URL
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const deviceUrl = await firstDevice.getAttribute('href');
|
|
|
|
if (deviceUrl) {
|
|
// Navigate directly to wireless tab
|
|
await page.goto(`${deviceUrl}?tab=wireless`);
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should be on wireless tab
|
|
await expect(page).toHaveURL(/tab=wireless/);
|
|
|
|
// Wireless content should be visible
|
|
const wirelessContent = page.locator(
|
|
'table, :has-text("MAC"), :has-text("Signal"), :has-text("No wireless clients")'
|
|
).first();
|
|
|
|
if (await wirelessContent.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(wirelessContent).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can switch between wireless and other tabs', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify we're on wireless tab
|
|
await expect(page).toHaveURL(/tab=wireless/);
|
|
|
|
// Switch to another tab (overview, ports, etc.)
|
|
const overviewTab = page.locator('a[href*="tab=overview"]').first();
|
|
|
|
if (await overviewTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await overviewTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should be on overview tab now
|
|
await expect(page).toHaveURL(/tab=overview/);
|
|
|
|
// Switch back to wireless
|
|
const wirelessTabAgain = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTabAgain.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTabAgain.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should be back on wireless tab
|
|
await expect(page).toHaveURL(/tab=wireless/);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('shows subscriber information when matched', async ({ page }) => {
|
|
await page.goto('/devices');
|
|
|
|
const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first();
|
|
|
|
if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await firstDevice.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to Wireless tab
|
|
const wirelessTab = page.locator('a[href*="tab=wireless"]').first();
|
|
|
|
if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await wirelessTab.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for Subscriber column/header
|
|
const subscriberHeader = page.locator('th:has-text("Subscriber")').first();
|
|
|
|
if (await subscriberHeader.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(subscriberHeader).toBeVisible();
|
|
|
|
// If there are clients, check if subscriber names appear
|
|
const clientRows = page.locator('table tbody tr');
|
|
|
|
if ((await clientRows.count()) > 0) {
|
|
// Subscriber column may show names or "—" for unmatched
|
|
const subscriberCell = page.locator('td').nth(2); // Assuming 3rd column
|
|
|
|
if (await subscriberCell.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
await expect(subscriberCell).toBeVisible();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|