diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 94ac5b7c..0e1be2c9 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,19 @@ +2026-03-06 +feat: improve Gaiia webhook setup instructions and descriptions + - Updated Gaiia provider description to explain what it enables + - Added "What webhooks enable" section explaining account, billing, and inventory events + - Enhanced webhook description to explain real-time vs scheduled sync difference + - Added webhook secret explanation text + - Improved setup instructions with bold formatting and verification tip + - Files: lib/towerops_web/live/org/integrations_live.ex, integrations_live.html.heex + +2026-03-06 +fix: global search (Cmd+K) not returning results + - handle_event("search") was reading from phx-value-query which contained stale assign + - Changed to read "value" key from native keyup event params + - Removed unused phx-value-query attribute from search input + - Files: lib/towerops_web/live/components/global_search_component.ex + 2026-03-06 feat: dynamic global pricing configuration with Stripe integration - Add default_free_devices and default_price_per_device to ApplicationSettings diff --git a/config/dev.exs b/config/dev.exs index a1d8042a..2d4036d4 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -24,6 +24,23 @@ config :logger, :file_log, config :logger, level: :info +# Configure file system watcher to ignore directories that don't need watching +# This prevents "too many open files" errors in development +config :phoenix, :code_reloader, + ignore: [ + ~r"deps/", + ~r"_build/", + ~r"node_modules/", + ~r"assets/node_modules/", + ~r"\.git/", + ~r"priv/static/", + ~r"priv/mibs/", + ~r"e2e/", + ~r"test/", + ~r"cover/", + ~r"doc/" + ] + # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime diff --git a/e2e/tests/activity-feed.spec.ts b/e2e/tests/activity-feed.spec.ts new file mode 100644 index 00000000..817b8c4d --- /dev/null +++ b/e2e/tests/activity-feed.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Activity Feed', () => { + test('can access organization activity feed', async ({ page }) => { + await page.goto('/activity'); + + await expect(page).toHaveURL(/\/activity/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows activity entries', async ({ page }) => { + await page.goto('/activity'); + + // Look for activity items + const activityEntry = page.locator( + '[data-activity], .activity-item, [role="listitem"]' + ).first(); + + if (await activityEntry.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(activityEntry).toBeVisible(); + } + }); + + test('can filter activity by type', async ({ page }) => { + await page.goto('/activity'); + + // Look for filter controls + const filterControl = page.locator( + 'select[name*="type"], button:has-text("Filter"), [role="tab"]' + ).first(); + + if (await filterControl.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(filterControl).toBeVisible(); + } + }); + + test('can filter activity by date range', async ({ page }) => { + await page.goto('/activity'); + + // Look for date filters + const dateFilter = page.locator( + 'input[type="date"], button:has-text("Date")' + ).first(); + + if (await dateFilter.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(dateFilter).toBeVisible(); + } + }); + + test('shows activity details', async ({ page }) => { + await page.goto('/activity'); + + // Look for activity metadata (user, timestamp, action) + const activityMeta = page.locator( + ':has-text("ago"), time, [data-timestamp]' + ).first(); + + if (await activityMeta.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(activityMeta).toBeVisible(); + } + }); + + test('can search activity', async ({ page }) => { + await page.goto('/activity'); + + // Look for search input + const searchInput = page.locator('input[type="search"], input[placeholder*="Search"]').first(); + + if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await searchInput.fill('test'); + await page.waitForTimeout(500); + } + }); +}); diff --git a/e2e/tests/auth-flows.spec.ts b/e2e/tests/auth-flows.spec.ts new file mode 100644 index 00000000..f1ad7e79 --- /dev/null +++ b/e2e/tests/auth-flows.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication Flows', () => { + test.describe('Registration', () => { + test('can access registration page', async ({ page }) => { + await page.goto('/users/register'); + + await expect(page).toHaveURL(/\/users\/register/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows registration form fields', async ({ page }) => { + await page.goto('/users/register'); + + // Look for email and password fields + const emailField = page.locator('input[type="email"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + + if (await emailField.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(emailField).toBeVisible(); + } + + if (await passwordField.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(passwordField).toBeVisible(); + } + }); + }); + + test.describe('Login', () => { + test('can access login page', async ({ page }) => { + await page.goto('/users/log-in'); + + await expect(page).toHaveURL(/\/users\/log-in/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows login form', async ({ page }) => { + await page.goto('/users/log-in'); + + const emailField = page.locator('input[type="email"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + const submitButton = page.locator('button[type="submit"]').first(); + + if (await emailField.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(emailField).toBeVisible(); + } + + if (await passwordField.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(passwordField).toBeVisible(); + } + + if (await submitButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(submitButton).toBeVisible(); + } + }); + + test('shows link to password reset', async ({ page }) => { + await page.goto('/users/log-in'); + + const resetLink = page.locator('a[href*="reset-password"]').first(); + + if (await resetLink.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(resetLink).toBeVisible(); + } + }); + + test('shows link to registration', async ({ page }) => { + await page.goto('/users/log-in'); + + const registerLink = page.locator('a[href*="register"]').first(); + + if (await registerLink.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(registerLink).toBeVisible(); + } + }); + }); + + test.describe('Password Reset', () => { + test('can access password reset page', async ({ page }) => { + await page.goto('/users/reset-password'); + + await expect(page).toHaveURL(/\/users\/reset-password/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows password reset form', async ({ page }) => { + await page.goto('/users/reset-password'); + + const emailField = page.locator('input[type="email"]').first(); + const submitButton = page.locator('button[type="submit"]').first(); + + if (await emailField.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(emailField).toBeVisible(); + } + + if (await submitButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(submitButton).toBeVisible(); + } + }); + }); + + test.describe('TOTP Enrollment', () => { + test('can access TOTP enrollment page when authenticated', async ({ page }) => { + await page.goto('/account/totp-enrollment'); + + // Will redirect to login if not authenticated + const currentUrl = page.url(); + if (currentUrl.includes('log-in')) { + // Expected - not authenticated + return; + } + + await expect(page).toHaveURL(/\/account\/totp-enrollment/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows QR code for TOTP setup', async ({ page }) => { + await page.goto('/account/totp-enrollment'); + + // Skip if redirected to login + if ((await page.url()).includes('log-in')) { + return; + } + + // Look for QR code or setup instructions + const qrCode = page.locator('canvas, img[alt*="QR"], [data-qr]').first(); + const setupInstructions = page.locator(':has-text("scan"), :has-text("authenticator")').first(); + + const qrVisible = await qrCode.isVisible({ timeout: 2000 }).catch(() => false); + const instructionsVisible = await setupInstructions.isVisible({ timeout: 2000 }).catch(() => false); + + if (qrVisible || instructionsVisible) { + // At least one should be visible + expect(qrVisible || instructionsVisible).toBe(true); + } + }); + }); + + test.describe('Email Confirmation', () => { + test('can access confirmation page', async ({ page }) => { + await page.goto('/users/confirm'); + + await expect(page).toHaveURL(/\/users\/confirm/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows confirmation instructions', async ({ page }) => { + await page.goto('/users/confirm'); + + const instructions = page.locator(':has-text("email"), :has-text("confirm")').first(); + + if (await instructions.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(instructions).toBeVisible(); + } + }); + }); +}); diff --git a/e2e/tests/device-graphs.spec.ts b/e2e/tests/device-graphs.spec.ts new file mode 100644 index 00000000..3c5b6210 --- /dev/null +++ b/e2e/tests/device-graphs.spec.ts @@ -0,0 +1,169 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Device Graphs', () => { + test('can access device from devices list', async ({ page }) => { + await page.goto('/devices'); + + // Find first device link + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + const href = await firstDevice.getAttribute('href'); + if (href && !href.includes('#!')) { + await firstDevice.click(); + await page.waitForTimeout(500); + } + } + }); + + test('can view sensor graphs', 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 graph sections + const graph = page.locator('[data-graph], canvas, svg, .chart').first(); + + if (await graph.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(graph).toBeVisible(); + } + } + }); + + test('shows graph time range selector', 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 time range selector + const timeRange = page.locator( + 'select[name*="range"], button:has-text("1h"), button:has-text("24h"), button:has-text("7d")' + ).first(); + + if (await timeRange.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(timeRange).toBeVisible(); + } + } + }); + + test('can switch between different sensor types', 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 sensor type tabs or buttons + const sensorTab = page.locator( + '[role="tab"], button:has-text("CPU"), button:has-text("Memory"), button:has-text("Traffic")' + ).first(); + + if (await sensorTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await sensorTab.click(); + await page.waitForTimeout(1000); + } + } + }); + + test('shows graph legend', 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 graph legend + const legend = page.locator( + '.legend, [data-legend], :has-text("Min"), :has-text("Max"), :has-text("Avg")' + ).first(); + + if (await legend.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(legend).toBeVisible(); + } + } + }); + + test('can view interface graphs', 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 interfaces section + const interfacesTab = page.locator( + 'a[href*="interfaces"], button:has-text("Interfaces"), :has-text("Interfaces")' + ).first(); + + if (await interfacesTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await interfacesTab.click(); + await page.waitForTimeout(1000); + + // Look for interface graphs + const interfaceGraph = page.locator('canvas, svg, [data-graph]').first(); + + if (await interfaceGraph.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(interfaceGraph).toBeVisible(); + } + } + } + }); + + test('can view check result graphs', 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 checks/monitoring section + const checksTab = page.locator( + 'a[href*="checks"], button:has-text("Checks"), :has-text("Monitoring")' + ).first(); + + if (await checksTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await checksTab.click(); + await page.waitForTimeout(1000); + } + } + }); + + test('can change graph time range', 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); + + // Find and click different time range + const timeButton = page.locator('button:has-text("24h"), button:has-text("7d")').first(); + + if (await timeButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await timeButton.click(); + await page.waitForTimeout(1000); + + // Graph should update (wait for potential loading state) + await page.waitForTimeout(500); + } + } + }); +}); diff --git a/e2e/tests/mikrotik-backup-compare.spec.ts b/e2e/tests/mikrotik-backup-compare.spec.ts new file mode 100644 index 00000000..6aa76220 --- /dev/null +++ b/e2e/tests/mikrotik-backup-compare.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; + +test.describe('MikroTik Backup Comparison', () => { + test('can access backups from device page', async ({ page }) => { + await page.goto('/devices'); + + // Find 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 backups tab or link + const backupsLink = page.locator( + 'a[href*="backups"], button:has-text("Backups"), :has-text("Backups")' + ).first(); + + if (await backupsLink.isVisible({ timeout: 2000 }).catch(() => false)) { + await backupsLink.click(); + await page.waitForTimeout(500); + } + } + }); + + test('shows list of backups for device', async ({ page }) => { + await page.goto('/backups'); + + // Look for backup entries + const backupEntry = page.locator('[data-backup], tr, [role="listitem"]').first(); + + if (await backupEntry.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(backupEntry).toBeVisible(); + } + }); + + test('can select backups for comparison', async ({ page }) => { + await page.goto('/backups'); + + // Look for compare functionality + const compareButton = page.locator( + 'button:has-text("Compare"), a:has-text("Compare")' + ).first(); + + if (await compareButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(compareButton).toBeVisible(); + } + }); + + test('shows backup comparison view', 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)) { + const deviceHref = await firstDevice.getAttribute('href'); + + if (deviceHref) { + // Try to access compare page directly (if device ID is available) + const deviceId = deviceHref.split('/').pop(); + + if (deviceId && !deviceId.includes('#')) { + await page.goto(`/devices/${deviceId}/backups/compare`); + await page.waitForTimeout(500); + + // Check if compare page loaded + if ((await page.url()).includes('/backups/compare')) { + await expect(page.locator('body')).toBeVisible(); + } + } + } + } + }); + + test('shows side-by-side diff view', 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)) { + const deviceHref = await firstDevice.getAttribute('href'); + + if (deviceHref) { + const deviceId = deviceHref.split('/').pop(); + + if (deviceId && !deviceId.includes('#')) { + await page.goto(`/devices/${deviceId}/backups/compare`); + await page.waitForTimeout(500); + + // Look for diff view + const diffView = page.locator( + '[data-diff], .diff-view, code, pre' + ).first(); + + if (await diffView.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(diffView).toBeVisible(); + } + } + } + } + }); + + test('highlights changes in diff', 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)) { + const deviceHref = await firstDevice.getAttribute('href'); + + if (deviceHref) { + const deviceId = deviceHref.split('/').pop(); + + if (deviceId && !deviceId.includes('#')) { + await page.goto(`/devices/${deviceId}/backups/compare`); + await page.waitForTimeout(500); + + // Look for highlighted changes (additions/deletions) + const highlighted = page.locator( + '.diff-added, .diff-removed, [data-change], .addition, .deletion' + ).first(); + + if (await highlighted.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(highlighted).toBeVisible(); + } + } + } + } + }); + + test('can select different backups to compare', 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)) { + const deviceHref = await firstDevice.getAttribute('href'); + + if (deviceHref) { + const deviceId = deviceHref.split('/').pop(); + + if (deviceId && !deviceId.includes('#')) { + await page.goto(`/devices/${deviceId}/backups/compare`); + await page.waitForTimeout(500); + + // Look for backup selectors + const backupSelector = page.locator('select, [data-backup-selector]').first(); + + if (await backupSelector.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(backupSelector).toBeVisible(); + } + } + } + } + }); +}); diff --git a/e2e/tests/onboarding.spec.ts b/e2e/tests/onboarding.spec.ts new file mode 100644 index 00000000..0f97b680 --- /dev/null +++ b/e2e/tests/onboarding.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Onboarding', () => { + test('can create new organization', async ({ page }) => { + await page.goto('/orgs/new'); + + // Check if we can access the new org page + const currentUrl = page.url(); + if (currentUrl.includes('log-in') || currentUrl.includes('sudo-verify')) { + // Requires authentication or sudo verification + return; + } + + await expect(page).toHaveURL(/\/orgs\/new/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows organization creation form', async ({ page }) => { + await page.goto('/orgs/new'); + + // Skip if redirected + if ((await page.url()).includes('log-in')) { + return; + } + + // Look for organization name field + const nameField = page.locator('input[name*="name"], input[id*="name"]').first(); + + if (await nameField.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(nameField).toBeVisible(); + } + }); + + test('shows submit button on org creation', async ({ page }) => { + await page.goto('/orgs/new'); + + // Skip if redirected + if ((await page.url()).includes('log-in')) { + return; + } + + const submitButton = page.locator('button[type="submit"], button:has-text("Create")').first(); + + if (await submitButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(submitButton).toBeVisible(); + } + }); +}); diff --git a/e2e/tests/sites-map.spec.ts b/e2e/tests/sites-map.spec.ts new file mode 100644 index 00000000..c312bec2 --- /dev/null +++ b/e2e/tests/sites-map.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Sites Map', () => { + test('can access sites map', async ({ page }) => { + await page.goto('/sites-map'); + + await expect(page).toHaveURL(/\/sites-map/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows geographic map', async ({ page }) => { + await page.goto('/sites-map'); + + // Look for map container + const mapContainer = page.locator( + '[id*="map"], .leaflet-container, [class*="map"], canvas' + ).first(); + + if (await mapContainer.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(mapContainer).toBeVisible(); + } + }); + + test('shows site markers on map', async ({ page }) => { + await page.goto('/sites-map'); + + // Look for map markers + const marker = page.locator( + '[data-marker], .leaflet-marker-icon, [class*="marker"], circle, path' + ).first(); + + if (await marker.isVisible({ timeout: 3000 }).catch(() => false)) { + await expect(marker).toBeVisible(); + } + }); + + test('can zoom in/out on map', async ({ page }) => { + await page.goto('/sites-map'); + + // Look for zoom controls + const zoomIn = page.locator( + 'button[aria-label*="Zoom in"], .leaflet-control-zoom-in, button:has-text("+")' + ).first(); + + if (await zoomIn.isVisible({ timeout: 2000 }).catch(() => false)) { + await zoomIn.click(); + await page.waitForTimeout(300); + } + }); + + test('can click on site marker', async ({ page }) => { + await page.goto('/sites-map'); + + // Look for clickable markers + const marker = page.locator( + '[data-marker], .leaflet-marker-icon, circle, path' + ).first(); + + if (await marker.isVisible({ timeout: 3000 }).catch(() => false)) { + await marker.click(); + await page.waitForTimeout(500); + + // Should show popup or tooltip + const popup = page.locator( + '.leaflet-popup, [role="tooltip"], [data-popup]' + ).first(); + + if (await popup.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(popup).toBeVisible(); + } + } + }); + + test('shows site details in popup', async ({ page }) => { + await page.goto('/sites-map'); + + const marker = page.locator( + '[data-marker], .leaflet-marker-icon, circle, path' + ).first(); + + if (await marker.isVisible({ timeout: 3000 }).catch(() => false)) { + await marker.click(); + await page.waitForTimeout(500); + + // Look for site information + const siteInfo = page.locator( + ':has-text("Site"), :has-text("Devices"), a[href*="/sites/"]' + ).first(); + + if (await siteInfo.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(siteInfo).toBeVisible(); + } + } + }); + + test('can filter sites on map', async ({ page }) => { + await page.goto('/sites-map'); + + // Look for filter controls + const filterControl = page.locator( + 'select, button:has-text("Filter"), input[type="search"]' + ).first(); + + if (await filterControl.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(filterControl).toBeVisible(); + } + }); + + test('can pan around map', async ({ page }) => { + await page.goto('/sites-map'); + + const mapContainer = page.locator( + '[id*="map"], .leaflet-container, [class*="map"]' + ).first(); + + if (await mapContainer.isVisible({ timeout: 3000 }).catch(() => false)) { + // Map should be interactive (can't fully test panning without mouse events) + await expect(mapContainer).toBeVisible(); + } + }); + + test('shows map legend', async ({ page }) => { + await page.goto('/sites-map'); + + // Look for map legend + const legend = page.locator( + '.legend, [data-legend], :has-text("Online"), :has-text("Offline")' + ).first(); + + if (await legend.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(legend).toBeVisible(); + } + }); +}); diff --git a/e2e/tests/team.spec.ts b/e2e/tests/team.spec.ts new file mode 100644 index 00000000..a7685e31 --- /dev/null +++ b/e2e/tests/team.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Team Collaboration', () => { + test.describe('Organization Switching', () => { + test('can view organizations list', async ({ page }) => { + await page.goto('/orgs'); + + await expect(page).toHaveURL(/\/orgs/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows available organizations', async ({ page }) => { + await page.goto('/orgs'); + + // Look for organization cards or list items + const orgCard = page.locator('[data-org], [data-organization], .org-card').first(); + const orgList = page.locator('a[href*="/dashboard"], button:has-text("Switch")').first(); + + const cardVisible = await orgCard.isVisible({ timeout: 2000 }).catch(() => false); + const listVisible = await orgList.isVisible({ timeout: 2000 }).catch(() => false); + + if (cardVisible || listVisible) { + // At least one should be visible + expect(cardVisible || listVisible).toBe(true); + } + }); + + test('can switch organizations', async ({ page }) => { + await page.goto('/orgs'); + + // Look for switch/select button + const switchButton = page.locator('button:has-text("Switch"), a:has-text("Select")').first(); + + if (await switchButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await switchButton.click(); + await page.waitForTimeout(500); + } + }); + }); + + test.describe('Team Members', () => { + test('can view team members in settings', async ({ page }) => { + await page.goto('/settings'); + + // Skip if redirected to sudo verification + if ((await page.url()).includes('sudo-verify')) { + return; + } + + // Look for team members section + const membersSection = page.locator( + ':has-text("Members"), :has-text("Team"), a[href*="members"]' + ).first(); + + if (await membersSection.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(membersSection).toBeVisible(); + } + }); + + test('shows invite button', async ({ page }) => { + await page.goto('/settings'); + + // Skip if redirected + if ((await page.url()).includes('sudo-verify')) { + return; + } + + const inviteButton = page.locator( + 'button:has-text("Invite"), a:has-text("Invite")' + ).first(); + + if (await inviteButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(inviteButton).toBeVisible(); + } + }); + }); + + test.describe('Invitations', () => { + test('invitation page requires token', async ({ page }) => { + // Try to access without token (should handle gracefully) + await page.goto('/invitations/invalid-token-example'); + + await expect(page.locator('body')).toBeVisible(); + + // Should show some kind of message (error or invitation form) + const message = page.locator( + ':has-text("invitation"), :has-text("expired"), :has-text("invalid")' + ).first(); + + if (await message.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(message).toBeVisible(); + } + }); + }); +}); diff --git a/e2e/tests/trace.spec.ts b/e2e/tests/trace.spec.ts new file mode 100644 index 00000000..4ef48c9c --- /dev/null +++ b/e2e/tests/trace.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Network Trace', () => { + test('can access trace page', async ({ page }) => { + await page.goto('/trace'); + + await expect(page).toHaveURL(/\/trace/); + await expect(page.locator('body')).toBeVisible(); + }); + + test('shows trace form', async ({ page }) => { + await page.goto('/trace'); + + // Look for target input field + const targetInput = page.locator( + 'input[name*="target"], input[placeholder*="host"], input[placeholder*="IP"]' + ).first(); + + if (await targetInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(targetInput).toBeVisible(); + } + }); + + test('shows trace type selector', async ({ page }) => { + await page.goto('/trace'); + + // Look for trace type options (ping, traceroute, etc.) + const traceType = page.locator( + 'select[name*="type"], button:has-text("Ping"), button:has-text("Traceroute")' + ).first(); + + if (await traceType.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(traceType).toBeVisible(); + } + }); + + test('has start trace button', async ({ page }) => { + await page.goto('/trace'); + + const startButton = page.locator( + 'button[type="submit"], button:has-text("Start"), button:has-text("Run"), button:has-text("Trace")' + ).first(); + + if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(startButton).toBeVisible(); + } + }); + + test('can select device for trace source', async ({ page }) => { + await page.goto('/trace'); + + // Look for device/agent selector + const deviceSelector = page.locator( + 'select[name*="device"], select[name*="agent"], select[name*="source"]' + ).first(); + + if (await deviceSelector.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(deviceSelector).toBeVisible(); + } + }); + + test('shows trace results area', async ({ page }) => { + await page.goto('/trace'); + + // Look for results container + const resultsArea = page.locator( + '[data-results], .results, pre, code, [id*="output"]' + ).first(); + + if (await resultsArea.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(resultsArea).toBeVisible(); + } + }); + + test('can view trace history', async ({ page }) => { + await page.goto('/trace'); + + // Look for history section + const history = page.locator( + ':has-text("History"), :has-text("Recent"), [data-history]' + ).first(); + + if (await history.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(history).toBeVisible(); + } + }); + + test('shows loading state during trace', async ({ page }) => { + await page.goto('/trace'); + + // Fill in a target + const targetInput = page.locator( + 'input[name*="target"], input[placeholder*="host"]' + ).first(); + + if (await targetInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await targetInput.fill('8.8.8.8'); + + // Try to start trace + const startButton = page.locator( + 'button[type="submit"], button:has-text("Start")' + ).first(); + + if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await startButton.click(); + await page.waitForTimeout(500); + + // Look for loading indicator + const loading = page.locator( + '[data-loading], .spinner, :has-text("Running"), :has-text("Loading")' + ).first(); + + if (await loading.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(loading).toBeVisible(); + } + } + } + }); + + test('can stop running trace', async ({ page }) => { + await page.goto('/trace'); + + // Look for stop button (might only appear during trace) + const stopButton = page.locator( + 'button:has-text("Stop"), button:has-text("Cancel")' + ).first(); + + if (await stopButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(stopButton).toBeVisible(); + } + }); +}); diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex index 247394c8..b5cb116c 100644 --- a/lib/towerops_web/live/org/integrations_live.ex +++ b/lib/towerops_web/live/org/integrations_live.ex @@ -23,7 +23,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do %{ id: "gaiia", name: "Gaiia", - description: "Full-featured ISP billing with GraphQL API and real-time webhooks.", + description: + "Sync subscribers, inventory, and billing data to enable outage impact analysis and equipment reconciliation.", icon: "hero-user-group" }, %{ diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index cbd402aa..5acfa4c1 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -323,12 +323,61 @@

{t("Webhook Configuration")}

-

+

{t( - "Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change." + "Webhooks keep your data in sync in real-time. Without webhooks, data is only updated during scheduled syncs (every %{interval} minutes). With webhooks enabled, changes in Gaiia are reflected here within seconds.", + interval: + if(@integrations["gaiia"], + do: @integrations["gaiia"].sync_interval_minutes, + else: 15 + ) )}

+
+
+ {t("What webhooks enable")} +
+ +
+
+

+ {t( + "Used to verify that incoming webhooks are genuinely from Gaiia. Keep this secret." + )} +

diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 17348eda..e65da2ce 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,3 +1,10 @@ +2026-03-06 — Integration Improvements +* Improved webhook setup instructions with detailed descriptions and step-by-step guidance +* Added descriptions of what each webhook event type enables for real-time data sync + +2026-03-06 — Search Fix +* Fixed global search (Cmd+K / Ctrl+K) not returning any results when typing + 2026-03-06 — Pricing Update * Updated pricing to $2/device/month (previously $3 on marketing, $1 in code) * First 10 devices remain free with all features