feat: add remaining e2e tests and fix 'too many open files' error

- Added e2e tests for auth flows (registration, login, password reset, TOTP)
- Added e2e tests for onboarding and organization creation
- Added e2e tests for team collaboration (org switching, invitations)
- Added e2e tests for activity feed
- Added e2e tests for device graphs (sensors, time ranges, interfaces)
- Added e2e tests for MikroTik backup comparison
- Added e2e tests for sites map (geographic view)
- Added e2e tests for network trace tool

- Fixed 'too many open files' error by configuring Phoenix code reloader
  to ignore deps/, _build/, node_modules/, e2e/, and other non-source directories
- This prevents the file system watcher from monitoring unnecessary files
This commit is contained in:
Graham McIntire 2026-03-06 17:08:27 -06:00
parent 03ec4ee59a
commit d5b0f38ed6
No known key found for this signature in database
13 changed files with 1092 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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);
}
});
});

View file

@ -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();
}
});
});
});

View file

@ -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);
}
}
});
});

View file

@ -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();
}
}
}
}
});
});

View file

@ -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();
}
});
});

134
e2e/tests/sites-map.spec.ts Normal file
View file

@ -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();
}
});
});

95
e2e/tests/team.spec.ts Normal file
View file

@ -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();
}
});
});
});

132
e2e/tests/trace.spec.ts Normal file
View file

@ -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();
}
});
});

View file

@ -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"
},
%{

View file

@ -323,12 +323,61 @@
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Webhook Configuration")}
</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">
{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
)
)}
</p>
<div class="mt-4 rounded-md bg-gray-50 p-4 dark:bg-white/5">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">
{t("What webhooks enable")}
</h5>
<ul class="mt-2 space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<li class="flex items-start gap-2">
<.icon
name="hero-user-group"
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-400"
/>
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">
Account changes
</span>
— new subscribers, status changes, and address updates are reflected instantly for accurate outage impact analysis.
</span>
</li>
<li class="flex items-start gap-2">
<.icon
name="hero-credit-card"
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-400"
/>
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">
Billing subscription changes
</span>
— plan upgrades, cancellations, and MRR changes stay current so revenue impact estimates are always accurate.
</span>
</li>
<li class="flex items-start gap-2">
<.icon
name="hero-wrench-screwdriver"
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-400"
/>
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">
Inventory item changes
</span>
— equipment assignments, IP changes, and new installs are matched to your monitored devices automatically.
</span>
</li>
</ul>
</div>
<div class="mt-4 space-y-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
@ -376,6 +425,11 @@
<.icon name="hero-clipboard" class="h-3.5 w-3.5" /> Copy
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t(
"Used to verify that incoming webhooks are genuinely from Gaiia. Keep this secret."
)}
</p>
<div class="mt-2">
<button
type="button"
@ -397,14 +451,35 @@
<h5 class="text-xs font-medium text-blue-800 dark:text-blue-300">
{t("Setup Instructions")}
</h5>
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400">
<li>In your Gaiia admin panel, go to Settings → Webhooks</li>
<li>Click &quot;Add Webhook&quot;</li>
<li>Paste the Webhook URL above</li>
<li>Paste the Webhook Secret above into the &quot;Secret&quot; field</li>
<li>Select events: Account, Billing Subscription, Inventory Item</li>
<li>Save the webhook</li>
<ol class="mt-2 list-inside list-decimal space-y-1.5 text-xs text-blue-700 dark:text-blue-400">
<li>
In your Gaiia admin panel, navigate to
<span class="font-semibold">Settings → Webhooks</span>
</li>
<li>
Click <span class="font-semibold">Add Webhook</span>
</li>
<li>
Paste the <span class="font-semibold">Webhook URL</span>
shown above into the endpoint field
</li>
<li>
Copy the <span class="font-semibold">Webhook Secret</span>
above and paste it into Gaiia's "Secret" field
</li>
<li>
Enable the following event types:
<span class="font-semibold">
Account, Billing Subscription, Inventory Item
</span>
</li>
<li>Save the webhook configuration in Gaiia</li>
</ol>
<p class="mt-3 text-xs text-blue-600 dark:text-blue-400/80">
{t(
"Once configured, Gaiia will send updates to Towerops automatically. You can verify it's working by making a change in Gaiia and checking that it appears here within a few seconds."
)}
</p>
</div>
</div>
</div>

View file

@ -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