feat: add on-call schedules and escalation policies
Built-in PagerDuty-equivalent system for on-call scheduling and alert escalation. Users can now manage schedules, rotation layers, overrides, and escalation policies directly in the app alongside PagerDuty. - On-call schedules with rotation layers (daily/weekly/custom), member management, and temporary overrides - Escalation policies with ordered rules, timeout-based escalation, and user/schedule targets - Automatic escalation via Oban worker with configurable repeat count - Email notifications via Swoosh for on-call alerts - Resolver computes who's on-call from layer stacking and overrides - AlertNotificationWorker integration: starts escalation alongside PagerDuty, acknowledges/resolves incidents on alert state changes - Device and organization schemas support escalation_policy_id - Escalation policy picker on device form - Schedules nav item with tabbed index (schedules + escalation policies) - Full CRUD UI for schedules, layers, members, overrides, rules, targets - 62 LiveView tests, 56 context/schema/resolver/escalation tests - 26 E2E Playwright tests for smoke and critical path coverage
This commit is contained in:
parent
3b97e437f2
commit
0cd2ed3567
45 changed files with 5028 additions and 3 deletions
526
e2e/tests/schedules.spec.ts
Normal file
526
e2e/tests/schedules.spec.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('On-Call Schedules & Escalation Policies', () => {
|
||||
let consoleErrors: string[];
|
||||
let pageErrors: Error[];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors = [];
|
||||
pageErrors = [];
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', error => {
|
||||
pageErrors.push(error);
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (consoleErrors.length > 0) {
|
||||
console.log('Console errors:', consoleErrors);
|
||||
}
|
||||
if (pageErrors.length > 0) {
|
||||
console.log('Page errors:', pageErrors);
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Smoke Tests', () => {
|
||||
test('schedules index loads without errors', async ({ page }) => {
|
||||
const response = await page.goto('/schedules');
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
});
|
||||
|
||||
test('new schedule form loads without errors', async ({ page }) => {
|
||||
const response = await page.goto('/schedules/new');
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
});
|
||||
|
||||
test('new escalation policy form loads without errors', async ({ page }) => {
|
||||
const response = await page.goto('/schedules/escalation-policies/new');
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
});
|
||||
|
||||
test('schedules page maintains socket connection', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const hasSocketError = consoleErrors.some(err =>
|
||||
err.includes('socket') ||
|
||||
err.includes('Jason') ||
|
||||
err.includes('encode')
|
||||
);
|
||||
|
||||
expect(hasSocketError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('nav bar shows Schedules link', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const schedulesLink = page.locator('a[href="/schedules"]').first();
|
||||
if (await schedulesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await expect(schedulesLink).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('can navigate to schedules page from nav', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const schedulesLink = page.locator('a[href="/schedules"]').first();
|
||||
if (await schedulesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await schedulesLink.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/schedules/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schedules Tab', () => {
|
||||
test('schedules page shows tabs for Schedules and Escalation Policies', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const schedulesTab = page.getByText('Schedules', { exact: true }).first();
|
||||
const policiesTab = page.getByText('Escalation Policies', { exact: true }).first();
|
||||
|
||||
await expect(schedulesTab).toBeVisible();
|
||||
await expect(policiesTab).toBeVisible();
|
||||
});
|
||||
|
||||
test('schedules tab shows empty state or schedule list', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Either shows empty state or a table of schedules
|
||||
const emptyState = page.getByText('No schedules');
|
||||
const scheduleTable = page.locator('table');
|
||||
|
||||
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
const hasTable = await scheduleTable.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
// One of these should be visible
|
||||
expect(hasEmptyState || hasTable).toBe(true);
|
||||
});
|
||||
|
||||
test('can navigate to new schedule form', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const newButton = page.locator('a[href="/schedules/new"]').first();
|
||||
if (await newButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await newButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/schedules\/new/);
|
||||
}
|
||||
});
|
||||
|
||||
test('new schedule form has required fields', async ({ page }) => {
|
||||
await page.goto('/schedules/new');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check for name input
|
||||
const nameInput = page.locator('input[name*="name"]').first();
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
// Check for timezone input
|
||||
const timezoneInput = page.locator('input[name*="timezone"]').first();
|
||||
await expect(timezoneInput).toBeVisible();
|
||||
|
||||
// Check for save button
|
||||
const saveButton = page.getByText('Save Schedule');
|
||||
await expect(saveButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create a new schedule', async ({ page }) => {
|
||||
await page.goto('/schedules/new');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const nameInput = page.locator('input[name*="name"]').first();
|
||||
const timezoneInput = page.locator('input[name*="timezone"]').first();
|
||||
|
||||
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const scheduleName = `E2E Test Schedule ${Date.now()}`;
|
||||
await nameInput.fill(scheduleName);
|
||||
await timezoneInput.fill('America/Chicago');
|
||||
|
||||
const saveButton = page.getByText('Save Schedule');
|
||||
if (await saveButton.isVisible()) {
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should redirect to the schedule show page
|
||||
await expect(page).toHaveURL(/\/schedules\/[a-f0-9-]+$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('new schedule form has back link to index', async ({ page }) => {
|
||||
await page.goto('/schedules/new');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Back arrow link
|
||||
const backLink = page.locator('a[href="/schedules"]').first();
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schedule Show Page', () => {
|
||||
test('schedule show page displays schedule details', async ({ page }) => {
|
||||
// First, create a schedule or navigate to an existing one
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Look for a schedule row in the table
|
||||
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await scheduleRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show schedule name and on-call status
|
||||
const onCallSection = page.getByText('Currently On-Call');
|
||||
await expect(onCallSection).toBeVisible();
|
||||
|
||||
// Should show layers section
|
||||
const layersSection = page.getByText('Layers', { exact: true });
|
||||
await expect(layersSection).toBeVisible();
|
||||
|
||||
// Should show overrides section
|
||||
const overridesSection = page.getByText('Overrides', { exact: true });
|
||||
await expect(overridesSection).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('schedule show page has edit and delete buttons', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await scheduleRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const editLink = page.getByText('Edit', { exact: true });
|
||||
const deleteButton = page.getByText('Delete', { exact: true });
|
||||
|
||||
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await expect(editLink).toBeVisible();
|
||||
}
|
||||
if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await expect(deleteButton).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can navigate to edit schedule from show page', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await scheduleRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const editLink = page.locator('a[href*="/edit"]').first();
|
||||
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await editLink.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/schedules\/[a-f0-9-]+\/edit/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('back button on show page goes to index', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await scheduleRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const backLink = page.locator('a[href="/schedules"]').first();
|
||||
if (await backLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await backLink.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/schedules$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can toggle the Add Layer form', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await scheduleRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const addLayerButton = page.getByText('Add Layer', { exact: true }).first();
|
||||
if (await addLayerButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Click to show the form
|
||||
await addLayerButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Layer form should now be visible with fields
|
||||
const layerNameInput = page.locator('input[name*="layer"][name*="name"]').first();
|
||||
const formVisible = await layerNameInput.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
if (formVisible) {
|
||||
await expect(layerNameInput).toBeVisible();
|
||||
|
||||
// Click cancel to hide
|
||||
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
||||
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await cancelButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can toggle the Add Override form', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await scheduleRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const addOverrideButton = page.getByText('Add Override', { exact: true }).first();
|
||||
if (await addOverrideButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Click to show the form
|
||||
await addOverrideButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Override form should have datetime fields
|
||||
const startTimeInput = page.locator('input[name*="start_time"]').first();
|
||||
const formVisible = await startTimeInput.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
if (formVisible) {
|
||||
await expect(startTimeInput).toBeVisible();
|
||||
|
||||
// Click cancel to hide
|
||||
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
||||
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await cancelButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Escalation Policies Tab', () => {
|
||||
test('can switch to escalation policies tab', async ({ page }) => {
|
||||
await page.goto('/schedules');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const policiesTab = page.locator('a[href*="tab=escalation-policies"]').first();
|
||||
if (await policiesTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await policiesTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/tab=escalation-policies/);
|
||||
}
|
||||
});
|
||||
|
||||
test('escalation policies tab shows empty state or policy list', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const emptyState = page.getByText('No escalation policies');
|
||||
const policyTable = page.locator('table');
|
||||
|
||||
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
const hasTable = await policyTable.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
expect(hasEmptyState || hasTable).toBe(true);
|
||||
});
|
||||
|
||||
test('can navigate to new escalation policy form', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const newButton = page.locator('a[href*="/schedules/escalation-policies/new"]').first();
|
||||
if (await newButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await newButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/new/);
|
||||
}
|
||||
});
|
||||
|
||||
test('new escalation policy form has required fields', async ({ page }) => {
|
||||
await page.goto('/schedules/escalation-policies/new');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const nameInput = page.locator('input[name*="name"]').first();
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
const repeatInput = page.locator('input[name*="repeat_count"]').first();
|
||||
await expect(repeatInput).toBeVisible();
|
||||
|
||||
const saveButton = page.getByText('Save Policy');
|
||||
await expect(saveButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create a new escalation policy', async ({ page }) => {
|
||||
await page.goto('/schedules/escalation-policies/new');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const nameInput = page.locator('input[name*="name"]').first();
|
||||
|
||||
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const policyName = `E2E Test Policy ${Date.now()}`;
|
||||
await nameInput.fill(policyName);
|
||||
|
||||
const saveButton = page.getByText('Save Policy');
|
||||
if (await saveButton.isVisible()) {
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should redirect to the policy show page
|
||||
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/[a-f0-9-]+$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('new escalation policy form has back link', async ({ page }) => {
|
||||
await page.goto('/schedules/escalation-policies/new');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const backLink = page.locator('a[href*="tab=escalation-policies"]').first();
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Escalation Policy Show Page', () => {
|
||||
test('policy show page displays policy details', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show the policy name
|
||||
const heading = page.locator('h1').first();
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// Should show escalation rules section
|
||||
const rulesSection = page.getByText('Escalation Rules');
|
||||
if (await rulesSection.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await expect(rulesSection).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('policy show page has edit and delete buttons', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const editLink = page.getByText('Edit', { exact: true });
|
||||
const deleteButton = page.getByText('Delete', { exact: true });
|
||||
|
||||
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await expect(editLink).toBeVisible();
|
||||
}
|
||||
if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await expect(deleteButton).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can navigate to edit policy from show page', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const editLink = page.locator('a[href*="/edit"]').first();
|
||||
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await editLink.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/[a-f0-9-]+\/edit/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('back button on policy show page goes to escalation policies tab', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const backLink = page.locator('a[href*="tab=escalation-policies"]').first();
|
||||
if (await backLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await backLink.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/tab=escalation-policies/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can toggle the Add Rule form on policy show page', async ({ page }) => {
|
||||
await page.goto('/schedules?tab=escalation-policies');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
||||
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const addRuleButton = page.getByText('Add Rule', { exact: true }).first();
|
||||
if (await addRuleButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await addRuleButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Rule form should be visible with timeout field
|
||||
const timeoutInput = page.locator('input[name="timeout_minutes"]').first();
|
||||
const formVisible = await timeoutInput.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
if (formVisible) {
|
||||
await expect(timeoutInput).toBeVisible();
|
||||
|
||||
// Click cancel to hide
|
||||
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
||||
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await cancelButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -68,6 +68,7 @@ defmodule Towerops.Devices.Device do
|
|||
|
||||
belongs_to :site, Site
|
||||
belongs_to :organization, Organization
|
||||
belongs_to :escalation_policy, Towerops.OnCall.EscalationPolicy
|
||||
|
||||
has_one :snmp_device, SnmpDevice
|
||||
has_many :agent_assignments, AgentAssignment
|
||||
|
|
@ -119,6 +120,7 @@ defmodule Towerops.Devices.Device do
|
|||
organization: NotLoaded.t() | Organization.t() | nil,
|
||||
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
|
||||
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
|
||||
escalation_policy_id: Ecto.UUID.t() | nil,
|
||||
outbound_links: NotLoaded.t() | [DeviceLink.t()],
|
||||
inbound_links: NotLoaded.t() | [DeviceLink.t()],
|
||||
inserted_at: DateTime.t(),
|
||||
|
|
@ -164,7 +166,8 @@ defmodule Towerops.Devices.Device do
|
|||
:mikrotik_enabled,
|
||||
:mikrotik_credential_source,
|
||||
:device_role,
|
||||
:device_role_source
|
||||
:device_role_source,
|
||||
:escalation_policy_id
|
||||
])
|
||||
|> validate_required([:ip_address, :organization_id])
|
||||
|> validate_name()
|
||||
|
|
@ -182,6 +185,7 @@ defmodule Towerops.Devices.Device do
|
|||
|> update_mikrotik_credential_source()
|
||||
|> foreign_key_constraint(:site_id)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
|> foreign_key_constraint(:escalation_policy_id)
|
||||
end
|
||||
|
||||
defp validate_name(changeset) do
|
||||
|
|
|
|||
182
lib/towerops/on_call.ex
Normal file
182
lib/towerops/on_call.ex
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
defmodule Towerops.OnCall do
|
||||
@moduledoc """
|
||||
Context for on-call schedules and escalation policies.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
alias Towerops.OnCall.EscalationRule
|
||||
alias Towerops.OnCall.EscalationTarget
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.LayerMember
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Resolver
|
||||
alias Towerops.OnCall.Schedule
|
||||
alias Towerops.Repo
|
||||
|
||||
# --- Schedules ---
|
||||
|
||||
def list_schedules(organization_id) do
|
||||
Schedule
|
||||
|> where([s], s.organization_id == ^organization_id)
|
||||
|> order_by([s], asc: s.name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_schedule!(id) do
|
||||
Schedule
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload(overrides: :user, layers: [members: :user])
|
||||
end
|
||||
|
||||
def get_schedule(id) do
|
||||
case Repo.get(Schedule, id) do
|
||||
nil -> nil
|
||||
schedule -> Repo.preload(schedule, overrides: :user, layers: [members: :user])
|
||||
end
|
||||
end
|
||||
|
||||
def create_schedule(attrs) do
|
||||
%Schedule{}
|
||||
|> Schedule.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_schedule(%Schedule{} = schedule, attrs) do
|
||||
schedule
|
||||
|> Schedule.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_schedule(%Schedule{} = schedule) do
|
||||
Repo.delete(schedule)
|
||||
end
|
||||
|
||||
def change_schedule(%Schedule{} = schedule, attrs \\ %{}) do
|
||||
Schedule.changeset(schedule, attrs)
|
||||
end
|
||||
|
||||
# --- Layers ---
|
||||
|
||||
def create_layer(attrs) do
|
||||
%Layer{}
|
||||
|> Layer.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_layer(%Layer{} = layer, attrs) do
|
||||
layer
|
||||
|> Layer.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_layer(%Layer{} = layer) do
|
||||
Repo.delete(layer)
|
||||
end
|
||||
|
||||
# --- Layer Members ---
|
||||
|
||||
def add_layer_member(attrs) do
|
||||
%LayerMember{}
|
||||
|> LayerMember.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def remove_layer_member(%LayerMember{} = member) do
|
||||
Repo.delete(member)
|
||||
end
|
||||
|
||||
# --- Overrides ---
|
||||
|
||||
def create_override(attrs) do
|
||||
%Override{}
|
||||
|> Override.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def delete_override(%Override{} = override) do
|
||||
Repo.delete(override)
|
||||
end
|
||||
|
||||
# --- Escalation Policies ---
|
||||
|
||||
def list_escalation_policies(organization_id) do
|
||||
EscalationPolicy
|
||||
|> where([p], p.organization_id == ^organization_id)
|
||||
|> order_by([p], asc: p.name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_escalation_policy!(id) do
|
||||
EscalationPolicy
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload(rules: :targets)
|
||||
end
|
||||
|
||||
def get_escalation_policy(id) do
|
||||
case Repo.get(EscalationPolicy, id) do
|
||||
nil -> nil
|
||||
policy -> Repo.preload(policy, rules: :targets)
|
||||
end
|
||||
end
|
||||
|
||||
def create_escalation_policy(attrs) do
|
||||
%EscalationPolicy{}
|
||||
|> EscalationPolicy.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_escalation_policy(%EscalationPolicy{} = policy, attrs) do
|
||||
policy
|
||||
|> EscalationPolicy.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_escalation_policy(%EscalationPolicy{} = policy) do
|
||||
Repo.delete(policy)
|
||||
end
|
||||
|
||||
def change_escalation_policy(%EscalationPolicy{} = policy, attrs \\ %{}) do
|
||||
EscalationPolicy.changeset(policy, attrs)
|
||||
end
|
||||
|
||||
# --- Escalation Rules ---
|
||||
|
||||
def create_escalation_rule(attrs) do
|
||||
%EscalationRule{}
|
||||
|> EscalationRule.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_escalation_rule(%EscalationRule{} = rule, attrs) do
|
||||
rule
|
||||
|> EscalationRule.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_escalation_rule(%EscalationRule{} = rule) do
|
||||
Repo.delete(rule)
|
||||
end
|
||||
|
||||
# --- Escalation Targets ---
|
||||
|
||||
def create_escalation_target(attrs) do
|
||||
%EscalationTarget{}
|
||||
|> EscalationTarget.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def delete_escalation_target(%EscalationTarget{} = target) do
|
||||
Repo.delete(target)
|
||||
end
|
||||
|
||||
# --- On-Call Resolution ---
|
||||
|
||||
def who_is_on_call(schedule_id, datetime \\ DateTime.utc_now()) do
|
||||
case get_schedule(schedule_id) do
|
||||
nil -> nil
|
||||
schedule -> Resolver.resolve(schedule, datetime)
|
||||
end
|
||||
end
|
||||
end
|
||||
213
lib/towerops/on_call/escalation.ex
Normal file
213
lib/towerops/on_call/escalation.ex
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
defmodule Towerops.OnCall.Escalation do
|
||||
@moduledoc """
|
||||
Manages the escalation lifecycle for on-call incidents.
|
||||
|
||||
When an alert fires:
|
||||
1. Creates an incident linked to the alert and escalation policy
|
||||
2. Resolves targets at the current rule position
|
||||
3. Sends notifications to on-call users
|
||||
4. Schedules a check for escalation timeout
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.Incident
|
||||
alias Towerops.OnCall.Notification
|
||||
alias Towerops.OnCall.Notifier
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Workers.EscalationCheckWorker
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Starts escalation for an alert using the given escalation policy.
|
||||
|
||||
Creates an incident, sends notifications to the first rule's targets,
|
||||
and schedules an escalation check.
|
||||
"""
|
||||
def start_escalation(_alert, nil), do: {:error, :no_policy}
|
||||
def start_escalation(_alert, ""), do: {:error, :no_policy}
|
||||
|
||||
def start_escalation(alert, policy_id) do
|
||||
case OnCall.get_escalation_policy(policy_id) do
|
||||
nil ->
|
||||
{:error, :no_policy}
|
||||
|
||||
policy ->
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
attrs = %{
|
||||
alert_id: alert.id,
|
||||
escalation_policy_id: policy.id,
|
||||
organization_id: alert.organization_id,
|
||||
status: "triggered",
|
||||
triggered_at: now,
|
||||
current_rule_position: 0,
|
||||
current_loop: 1
|
||||
}
|
||||
|
||||
with {:ok, incident} <- create_incident(attrs) do
|
||||
execute_current_rule(incident, policy)
|
||||
schedule_escalation_check(incident, policy)
|
||||
{:ok, incident}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Acknowledges an incident, stopping further escalation.
|
||||
"""
|
||||
def acknowledge_incident(incident_id, user_id) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
Incident
|
||||
|> Repo.get!(incident_id)
|
||||
|> Incident.changeset(%{
|
||||
status: "acknowledged",
|
||||
acknowledged_at: now,
|
||||
acknowledged_by_id: user_id
|
||||
})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resolves an incident.
|
||||
"""
|
||||
def resolve_incident(incident_id, user_id) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
Incident
|
||||
|> Repo.get!(incident_id)
|
||||
|> Incident.changeset(%{
|
||||
status: "resolved",
|
||||
resolved_at: now,
|
||||
resolved_by_id: user_id
|
||||
})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds the active (triggered or acknowledged) incident for an alert.
|
||||
"""
|
||||
def find_incident_for_alert(alert_id) do
|
||||
Incident
|
||||
|> where([i], i.alert_id == ^alert_id and i.status in ["triggered", "acknowledged"])
|
||||
|> order_by([i], desc: :triggered_at)
|
||||
|> limit(1)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called by EscalationCheckWorker. Checks if the incident needs to escalate
|
||||
to the next rule.
|
||||
"""
|
||||
def check_and_escalate(incident_id) do
|
||||
case Repo.get(Incident, incident_id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
%{status: status} when status in ["acknowledged", "resolved"] ->
|
||||
:ok
|
||||
|
||||
incident ->
|
||||
policy = OnCall.get_escalation_policy!(incident.escalation_policy_id)
|
||||
advance_escalation(incident, policy)
|
||||
end
|
||||
end
|
||||
|
||||
# --- Private ---
|
||||
|
||||
defp create_incident(attrs) do
|
||||
%Incident{}
|
||||
|> Incident.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp execute_current_rule(incident, policy) do
|
||||
rule = Enum.find(policy.rules, &(&1.position == incident.current_rule_position))
|
||||
|
||||
if rule do
|
||||
users = resolve_targets(rule.targets)
|
||||
|
||||
Enum.each(users, fn user ->
|
||||
Notifier.notify(user, incident)
|
||||
log_notification(incident, user)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_targets(targets) do
|
||||
targets
|
||||
|> Enum.flat_map(&resolve_target/1)
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
end
|
||||
|
||||
defp resolve_target(%{target_type: "user", user_id: user_id}) do
|
||||
case Repo.get(Towerops.Accounts.User, user_id) do
|
||||
nil -> []
|
||||
user -> [user]
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_target(%{target_type: "schedule", schedule_id: schedule_id}) do
|
||||
case OnCall.who_is_on_call(schedule_id) do
|
||||
nil -> []
|
||||
user -> [user]
|
||||
end
|
||||
end
|
||||
|
||||
defp log_notification(incident, user) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
%Notification{}
|
||||
|> Notification.changeset(%{
|
||||
incident_id: incident.id,
|
||||
user_id: user.id,
|
||||
channel: "email",
|
||||
status: "sent",
|
||||
sent_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp schedule_escalation_check(incident, policy) do
|
||||
rule = Enum.find(policy.rules, &(&1.position == incident.current_rule_position))
|
||||
timeout_seconds = (rule && rule.timeout_minutes * 60) || 1800
|
||||
|
||||
%{incident_id: incident.id}
|
||||
|> EscalationCheckWorker.new(schedule_in: timeout_seconds)
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp advance_escalation(incident, policy) do
|
||||
rules = Enum.sort_by(policy.rules, & &1.position)
|
||||
max_position = if rules == [], do: 0, else: List.last(rules).position
|
||||
|
||||
cond do
|
||||
incident.current_rule_position < max_position ->
|
||||
next_position = incident.current_rule_position + 1
|
||||
|
||||
{:ok, updated} =
|
||||
incident
|
||||
|> Incident.changeset(%{current_rule_position: next_position})
|
||||
|> Repo.update()
|
||||
|
||||
execute_current_rule(updated, policy)
|
||||
schedule_escalation_check(updated, policy)
|
||||
|
||||
incident.current_loop < policy.repeat_count ->
|
||||
{:ok, updated} =
|
||||
incident
|
||||
|> Incident.changeset(%{current_rule_position: 0, current_loop: incident.current_loop + 1})
|
||||
|> Repo.update()
|
||||
|
||||
execute_current_rule(updated, policy)
|
||||
schedule_escalation_check(updated, policy)
|
||||
|
||||
true ->
|
||||
Logger.info("Escalation exhausted for incident #{incident.id}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
36
lib/towerops/on_call/escalation_policy.ex
Normal file
36
lib/towerops/on_call/escalation_policy.ex
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
defmodule Towerops.OnCall.EscalationPolicy do
|
||||
@moduledoc """
|
||||
Schema for escalation policies that define notification rules.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.OnCall.EscalationRule
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "escalation_policies" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :repeat_count, :integer, default: 3
|
||||
|
||||
belongs_to :organization, Organization
|
||||
|
||||
has_many :rules, EscalationRule, preload_order: [asc: :position]
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(name organization_id)a
|
||||
@optional_fields ~w(description repeat_count)a
|
||||
|
||||
def changeset(policy, attrs) do
|
||||
policy
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_number(:repeat_count, greater_than: 0)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
35
lib/towerops/on_call/escalation_rule.ex
Normal file
35
lib/towerops/on_call/escalation_rule.ex
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
defmodule Towerops.OnCall.EscalationRule do
|
||||
@moduledoc """
|
||||
Schema for a step within an escalation policy.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
alias Towerops.OnCall.EscalationTarget
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "escalation_rules" do
|
||||
field :position, :integer, default: 0
|
||||
field :timeout_minutes, :integer, default: 30
|
||||
|
||||
belongs_to :escalation_policy, EscalationPolicy
|
||||
|
||||
has_many :targets, EscalationTarget
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(position timeout_minutes escalation_policy_id)a
|
||||
|
||||
def changeset(rule, attrs) do
|
||||
rule
|
||||
|> cast(attrs, @required_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_number(:timeout_minutes, greater_than: 0)
|
||||
|> validate_number(:position, greater_than_or_equal_to: 0)
|
||||
|> foreign_key_constraint(:escalation_policy_id)
|
||||
end
|
||||
end
|
||||
39
lib/towerops/on_call/escalation_target.ex
Normal file
39
lib/towerops/on_call/escalation_target.ex
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
defmodule Towerops.OnCall.EscalationTarget do
|
||||
@moduledoc """
|
||||
Schema for notification targets at each escalation step.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.OnCall.EscalationRule
|
||||
alias Towerops.OnCall.Schedule
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "escalation_targets" do
|
||||
field :target_type, :string
|
||||
|
||||
belongs_to :escalation_rule, EscalationRule
|
||||
belongs_to :schedule, Schedule
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@valid_target_types ~w(schedule user)
|
||||
|
||||
@required_fields ~w(target_type escalation_rule_id)a
|
||||
@optional_fields ~w(schedule_id user_id)a
|
||||
|
||||
def changeset(target, attrs) do
|
||||
target
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_inclusion(:target_type, @valid_target_types)
|
||||
|> foreign_key_constraint(:escalation_rule_id)
|
||||
|> foreign_key_constraint(:schedule_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
end
|
||||
end
|
||||
50
lib/towerops/on_call/incident.ex
Normal file
50
lib/towerops/on_call/incident.ex
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
defmodule Towerops.OnCall.Incident do
|
||||
@moduledoc """
|
||||
Schema for tracking active escalation state for an alert.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Alerts.Alert
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
alias Towerops.OnCall.Notification
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "on_call_incidents" do
|
||||
field :status, :string, default: "triggered"
|
||||
field :current_rule_position, :integer, default: 0
|
||||
field :current_loop, :integer, default: 1
|
||||
field :triggered_at, :utc_datetime
|
||||
field :acknowledged_at, :utc_datetime
|
||||
field :resolved_at, :utc_datetime
|
||||
|
||||
belongs_to :alert, Alert
|
||||
belongs_to :escalation_policy, EscalationPolicy
|
||||
belongs_to :organization, Organization
|
||||
belongs_to :acknowledged_by, User
|
||||
belongs_to :resolved_by, User
|
||||
|
||||
has_many :notifications, Notification
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@valid_statuses ~w(triggered acknowledged resolved)
|
||||
|
||||
@required_fields ~w(status triggered_at alert_id escalation_policy_id organization_id)a
|
||||
@optional_fields ~w(current_rule_position current_loop acknowledged_at resolved_at acknowledged_by_id resolved_by_id)a
|
||||
|
||||
def changeset(incident, attrs) do
|
||||
incident
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_inclusion(:status, @valid_statuses)
|
||||
|> foreign_key_constraint(:alert_id)
|
||||
|> foreign_key_constraint(:escalation_policy_id)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
50
lib/towerops/on_call/layer.ex
Normal file
50
lib/towerops/on_call/layer.ex
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
defmodule Towerops.OnCall.Layer do
|
||||
@moduledoc """
|
||||
Schema for a rotation layer within an on-call schedule.
|
||||
|
||||
Layers stack — higher position layers override lower layers where they have coverage.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.OnCall.LayerMember
|
||||
alias Towerops.OnCall.Schedule
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "on_call_layers" do
|
||||
field :name, :string
|
||||
field :position, :integer, default: 0
|
||||
field :rotation_type, :string
|
||||
field :rotation_interval, :integer, default: 1
|
||||
field :handoff_time, :time
|
||||
field :handoff_day, :integer
|
||||
field :start_date, :utc_datetime
|
||||
field :restriction_type, :string
|
||||
field :restrictions, :map
|
||||
|
||||
belongs_to :schedule, Schedule
|
||||
|
||||
has_many :members, LayerMember, preload_order: [asc: :position]
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@valid_rotation_types ~w(daily weekly custom)
|
||||
@valid_restriction_types ~w(time_of_day time_of_week)
|
||||
|
||||
@required_fields ~w(name position rotation_type rotation_interval handoff_time start_date schedule_id)a
|
||||
@optional_fields ~w(handoff_day restriction_type restrictions)a
|
||||
|
||||
def changeset(layer, attrs) do
|
||||
layer
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_inclusion(:rotation_type, @valid_rotation_types)
|
||||
|> validate_inclusion(:restriction_type, @valid_restriction_types)
|
||||
|> validate_number(:rotation_interval, greater_than: 0)
|
||||
|> validate_number(:handoff_day, greater_than_or_equal_to: 0, less_than_or_equal_to: 6)
|
||||
|> foreign_key_constraint(:schedule_id)
|
||||
end
|
||||
end
|
||||
34
lib/towerops/on_call/layer_member.ex
Normal file
34
lib/towerops/on_call/layer_member.ex
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Towerops.OnCall.LayerMember do
|
||||
@moduledoc """
|
||||
Schema for a user's position in a layer's rotation.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.OnCall.Layer
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "on_call_layer_members" do
|
||||
field :position, :integer, default: 0
|
||||
|
||||
belongs_to :layer, Layer
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(position layer_id user_id)a
|
||||
|
||||
def changeset(member, attrs) do
|
||||
member
|
||||
|> cast(attrs, @required_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_number(:position, greater_than_or_equal_to: 0)
|
||||
|> foreign_key_constraint(:layer_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> unique_constraint([:layer_id, :user_id])
|
||||
end
|
||||
end
|
||||
38
lib/towerops/on_call/notification.ex
Normal file
38
lib/towerops/on_call/notification.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Towerops.OnCall.Notification do
|
||||
@moduledoc """
|
||||
Schema for logging sent on-call notifications.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.OnCall.Incident
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "on_call_notifications" do
|
||||
field :channel, :string, default: "email"
|
||||
field :sent_at, :utc_datetime
|
||||
field :status, :string, default: "sent"
|
||||
field :error_message, :string
|
||||
|
||||
belongs_to :incident, Incident
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(channel status incident_id user_id)a
|
||||
@optional_fields ~w(sent_at error_message)a
|
||||
|
||||
def changeset(notification, attrs) do
|
||||
notification
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_inclusion(:channel, ~w(email))
|
||||
|> validate_inclusion(:status, ~w(sent failed))
|
||||
|> foreign_key_constraint(:incident_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
end
|
||||
end
|
||||
44
lib/towerops/on_call/notifier.ex
Normal file
44
lib/towerops/on_call/notifier.ex
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
defmodule Towerops.OnCall.Notifier do
|
||||
@moduledoc """
|
||||
Sends on-call alert notifications to users via email.
|
||||
"""
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Towerops.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends an alert notification email to the given user about an incident.
|
||||
"""
|
||||
def notify(user, incident) do
|
||||
from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"})
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(user.email)
|
||||
|> from(from_address)
|
||||
|> subject("[Towerops Alert] Incident triggered")
|
||||
|> text_body("""
|
||||
An alert has been triggered and you are on-call.
|
||||
|
||||
Incident ID: #{incident.id}
|
||||
Status: #{incident.status}
|
||||
Triggered at: #{incident.triggered_at}
|
||||
|
||||
Please acknowledge or resolve this incident in Towerops.
|
||||
""")
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _} ->
|
||||
Logger.info("On-call notification sent to #{user.email} for incident #{incident.id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send on-call notification to #{user.email}: #{inspect(reason)}")
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
48
lib/towerops/on_call/override.ex
Normal file
48
lib/towerops/on_call/override.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule Towerops.OnCall.Override do
|
||||
@moduledoc """
|
||||
Schema for temporary on-call overrides on a schedule.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.OnCall.Schedule
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "on_call_overrides" do
|
||||
field :start_time, :utc_datetime
|
||||
field :end_time, :utc_datetime
|
||||
|
||||
belongs_to :schedule, Schedule
|
||||
belongs_to :user, User
|
||||
belongs_to :created_by, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(start_time end_time schedule_id user_id)a
|
||||
@optional_fields ~w(created_by_id)a
|
||||
|
||||
def changeset(override, attrs) do
|
||||
override
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_end_after_start()
|
||||
|> foreign_key_constraint(:schedule_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> foreign_key_constraint(:created_by_id)
|
||||
end
|
||||
|
||||
defp validate_end_after_start(changeset) do
|
||||
start_time = get_field(changeset, :start_time)
|
||||
end_time = get_field(changeset, :end_time)
|
||||
|
||||
if start_time && end_time && DateTime.compare(end_time, start_time) != :gt do
|
||||
add_error(changeset, :end_time, "must be after start time")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
121
lib/towerops/on_call/resolver.ex
Normal file
121
lib/towerops/on_call/resolver.ex
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
defmodule Towerops.OnCall.Resolver do
|
||||
@moduledoc """
|
||||
Pure computation of who's on-call for a given schedule at a given time.
|
||||
|
||||
Layers stack — higher position layers override lower layers where they have coverage.
|
||||
Overrides take precedence over all layers.
|
||||
"""
|
||||
|
||||
alias Towerops.OnCall.Schedule
|
||||
|
||||
@seconds_per_day 86_400
|
||||
@seconds_per_week 604_800
|
||||
|
||||
@doc """
|
||||
Resolves who is on-call for the given schedule at the given datetime.
|
||||
|
||||
The schedule must have `layers` (with `:members`) and `overrides` preloaded.
|
||||
|
||||
Returns the on-call `%User{}` or `nil` if no one is on-call.
|
||||
"""
|
||||
@spec resolve(Schedule.t(), DateTime.t()) :: map() | nil
|
||||
def resolve(%Schedule{} = schedule, %DateTime{} = datetime) do
|
||||
case resolve_override(schedule.overrides, datetime) do
|
||||
{:ok, user} -> user
|
||||
:none -> resolve_layers(schedule.layers, datetime)
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_override(overrides, datetime) do
|
||||
overrides
|
||||
|> Enum.find(fn override ->
|
||||
DateTime.compare(datetime, override.start_time) in [:gt, :eq] and
|
||||
DateTime.before?(datetime, override.end_time)
|
||||
end)
|
||||
|> case do
|
||||
nil -> :none
|
||||
override -> {:ok, override.user}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_layers(layers, datetime) do
|
||||
layers
|
||||
|> Enum.sort_by(& &1.position, :desc)
|
||||
|> Enum.reduce_while(nil, fn layer, _acc ->
|
||||
case resolve_layer(layer, datetime) do
|
||||
nil -> {:cont, nil}
|
||||
user -> {:halt, user}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_layer(layer, datetime) do
|
||||
with true <- datetime_after_start?(layer, datetime),
|
||||
true <- within_restriction?(layer, datetime),
|
||||
{:ok, user} <- compute_rotation(layer, datetime) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp datetime_after_start?(layer, datetime) do
|
||||
DateTime.compare(datetime, layer.start_date) in [:gt, :eq]
|
||||
end
|
||||
|
||||
defp within_restriction?(%{restriction_type: nil}, _datetime), do: true
|
||||
|
||||
defp within_restriction?(%{restriction_type: "time_of_day", restrictions: restrictions}, datetime) do
|
||||
start_time = parse_time(restrictions["start_time"])
|
||||
end_time = parse_time(restrictions["end_time"])
|
||||
current_time = DateTime.to_time(datetime)
|
||||
|
||||
if Time.after?(start_time, end_time) do
|
||||
# Overnight restriction (e.g., 18:00 - 08:00)
|
||||
Time.compare(current_time, start_time) in [:gt, :eq] or
|
||||
Time.before?(current_time, end_time)
|
||||
else
|
||||
# Same-day restriction (e.g., 09:00 - 17:00)
|
||||
Time.compare(current_time, start_time) in [:gt, :eq] and
|
||||
Time.before?(current_time, end_time)
|
||||
end
|
||||
end
|
||||
|
||||
defp within_restriction?(_layer, _datetime), do: true
|
||||
|
||||
defp compute_rotation(%{members: []}, _datetime), do: nil
|
||||
|
||||
defp compute_rotation(layer, datetime) do
|
||||
members = Enum.sort_by(layer.members, & &1.position)
|
||||
member_count = length(members)
|
||||
|
||||
if member_count == 0 do
|
||||
nil
|
||||
else
|
||||
elapsed_seconds = DateTime.diff(datetime, layer.start_date, :second)
|
||||
rotation_seconds = rotation_duration_seconds(layer)
|
||||
position = elapsed_seconds |> div(rotation_seconds) |> rem(member_count)
|
||||
|
||||
member = Enum.at(members, position)
|
||||
{:ok, member.user}
|
||||
end
|
||||
end
|
||||
|
||||
defp rotation_duration_seconds(%{rotation_type: "daily", rotation_interval: interval}) do
|
||||
interval * @seconds_per_day
|
||||
end
|
||||
|
||||
defp rotation_duration_seconds(%{rotation_type: "weekly", rotation_interval: interval}) do
|
||||
interval * @seconds_per_week
|
||||
end
|
||||
|
||||
defp rotation_duration_seconds(%{rotation_type: "custom", rotation_interval: interval}) do
|
||||
# Custom defaults to days
|
||||
interval * @seconds_per_day
|
||||
end
|
||||
|
||||
defp parse_time(time_string) when is_binary(time_string) do
|
||||
{:ok, time} = Time.from_iso8601(time_string <> ":00")
|
||||
time
|
||||
end
|
||||
end
|
||||
37
lib/towerops/on_call/schedule.ex
Normal file
37
lib/towerops/on_call/schedule.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule Towerops.OnCall.Schedule do
|
||||
@moduledoc """
|
||||
Schema for on-call schedules containing rotation layers.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "on_call_schedules" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :timezone, :string
|
||||
|
||||
belongs_to :organization, Organization
|
||||
|
||||
has_many :layers, Layer, preload_order: [asc: :position]
|
||||
has_many :overrides, Override
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(name timezone organization_id)a
|
||||
@optional_fields ~w(description)a
|
||||
|
||||
def changeset(schedule, attrs) do
|
||||
schedule
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -67,6 +67,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
field :device_count, :integer, virtual: true
|
||||
|
||||
belongs_to :default_agent_token, AgentToken
|
||||
belongs_to :default_escalation_policy, Towerops.OnCall.EscalationPolicy
|
||||
|
||||
has_many :memberships, Membership
|
||||
has_many :users, through: [:memberships, :user]
|
||||
|
|
@ -110,6 +111,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
last_synced_device_count: integer() | nil,
|
||||
default_agent_token_id: Ecto.UUID.t() | nil,
|
||||
default_agent_token: NotLoaded.t() | AgentToken.t() | nil,
|
||||
default_escalation_policy_id: Ecto.UUID.t() | nil,
|
||||
memberships: NotLoaded.t() | [Membership.t()],
|
||||
users: NotLoaded.t() | [Towerops.Accounts.User.t()],
|
||||
invitations: NotLoaded.t() | [Invitation.t()],
|
||||
|
|
@ -148,7 +150,8 @@ defmodule Towerops.Organizations.Organization do
|
|||
:subscription_current_period_end,
|
||||
:payment_method_status,
|
||||
:last_billing_sync_at,
|
||||
:last_synced_device_count
|
||||
:last_synced_device_count,
|
||||
:default_escalation_policy_id
|
||||
])
|
||||
|> validate_required([:name])
|
||||
|> validate_length(:name, min: 2, max: 100)
|
||||
|
|
@ -161,6 +164,7 @@ defmodule Towerops.Organizations.Organization do
|
|||
|> validate_required([:slug])
|
||||
|> unique_constraint(:slug)
|
||||
|> foreign_key_constraint(:default_agent_token_id)
|
||||
|> foreign_key_constraint(:default_escalation_policy_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Devices
|
||||
alias Towerops.OnCall.Escalation
|
||||
alias Towerops.PagerDuty.Notifier
|
||||
|
||||
require Logger
|
||||
|
|
@ -20,7 +21,9 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
def perform(%Oban.Job{args: %{"action" => "trigger", "alert_id" => alert_id}}) do
|
||||
with {:ok, alert} <- fetch_alert(alert_id),
|
||||
{:ok, device} <- fetch_device(alert.device_id) do
|
||||
Notifier.notify_trigger(alert, device)
|
||||
pagerduty_result = Notifier.notify_trigger(alert, device)
|
||||
maybe_start_escalation(alert, device)
|
||||
pagerduty_result
|
||||
else
|
||||
{:error, :alert_not_found} ->
|
||||
Logger.warning("Alert #{alert_id} not found for notification")
|
||||
|
|
@ -32,6 +35,13 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
|
||||
{:error, :not_configured} ->
|
||||
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
|
||||
|
||||
# Still try built-in escalation even if PagerDuty isn't configured
|
||||
with {:ok, alert} <- fetch_alert(alert_id),
|
||||
{:ok, device} <- fetch_device(alert.device_id) do
|
||||
maybe_start_escalation(alert, device)
|
||||
end
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -43,6 +53,8 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
def perform(%Oban.Job{args: %{"action" => "acknowledge", "alert_id" => alert_id}}) do
|
||||
case fetch_alert(alert_id) do
|
||||
{:ok, alert} ->
|
||||
maybe_acknowledge_incident(alert)
|
||||
|
||||
case Notifier.notify_acknowledge(alert) do
|
||||
{:error, :not_configured} ->
|
||||
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
|
||||
|
|
@ -65,6 +77,8 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
def perform(%Oban.Job{args: %{"action" => "resolve", "alert_id" => alert_id}}) do
|
||||
case fetch_alert(alert_id) do
|
||||
{:ok, alert} ->
|
||||
maybe_resolve_incident(alert)
|
||||
|
||||
case Notifier.notify_resolve(alert) do
|
||||
{:error, :not_configured} ->
|
||||
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
|
||||
|
|
@ -103,6 +117,46 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp maybe_start_escalation(alert, device) do
|
||||
policy_id = device.escalation_policy_id || device.organization.default_escalation_policy_id
|
||||
|
||||
if policy_id do
|
||||
case Escalation.start_escalation(alert, policy_id) do
|
||||
{:ok, _incident} ->
|
||||
Logger.info("Started built-in escalation for alert #{alert.id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to start escalation for alert #{alert.id}: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_acknowledge_incident(alert) do
|
||||
case Escalation.find_incident_for_alert(alert.id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
incident ->
|
||||
case Escalation.acknowledge_incident(incident.id, nil) do
|
||||
{:ok, _} -> Logger.info("Acknowledged incident #{incident.id} for alert #{alert.id}")
|
||||
{:error, reason} -> Logger.warning("Failed to acknowledge incident: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_resolve_incident(alert) do
|
||||
case Escalation.find_incident_for_alert(alert.id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
incident ->
|
||||
case Escalation.resolve_incident(incident.id, nil) do
|
||||
{:ok, _} -> Logger.info("Resolved incident #{incident.id} for alert #{alert.id}")
|
||||
{:error, reason} -> Logger.warning("Failed to resolve incident: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_alert(alert_id) do
|
||||
case Alerts.get_alert(alert_id) do
|
||||
nil -> {:error, :alert_not_found}
|
||||
|
|
|
|||
18
lib/towerops/workers/escalation_check_worker.ex
Normal file
18
lib/towerops/workers/escalation_check_worker.ex
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
defmodule Towerops.Workers.EscalationCheckWorker do
|
||||
@moduledoc """
|
||||
Oban worker that checks if an on-call incident needs to escalate
|
||||
to the next rule after the timeout period.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
queue: :notifications,
|
||||
max_attempts: 3,
|
||||
priority: 1
|
||||
|
||||
alias Towerops.OnCall.Escalation
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"incident_id" => incident_id}}) do
|
||||
Escalation.check_and_escalate(incident_id)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
@ -287,6 +287,12 @@ defmodule ToweropsWeb.Layouts do
|
|||
>
|
||||
{t("Maintenance")}
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/schedules"}
|
||||
active={@active_page == "schedules"}
|
||||
>
|
||||
{t("Schedules")}
|
||||
</.nav_link>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||
|
|
@ -582,6 +588,9 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
|
||||
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
|
||||
<.icon name="hero-calendar-days" class="size-5" /> {t("Schedules")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- Secondary nav -->
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
alias Towerops.Agents
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
|
|
@ -22,6 +23,9 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
cloud_pollers = Agents.list_cloud_pollers()
|
||||
all_agents = agents ++ cloud_pollers
|
||||
|
||||
# Load escalation policies for this organization
|
||||
escalation_policies = OnCall.list_escalation_policies(organization.id)
|
||||
|
||||
# Load device quota
|
||||
{current_devices, device_limit} =
|
||||
SubscriptionLimits.device_quota(organization)
|
||||
|
|
@ -38,6 +42,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
|> assign(:organization, organization)
|
||||
|> assign(:available_sites, sites)
|
||||
|> assign(:available_agents, all_agents)
|
||||
|> assign(:escalation_policies, escalation_policies)
|
||||
|> assign(:preselected_site_id, params["site_id"])
|
||||
|> assign(:snmp_test_result, nil)
|
||||
|> assign(:duplicate_device, nil)
|
||||
|
|
|
|||
|
|
@ -299,6 +299,23 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @escalation_policies != [] do %>
|
||||
<div class="col-span-full">
|
||||
<.input
|
||||
field={@form[:escalation_policy_id]}
|
||||
type="select"
|
||||
label={t("Escalation Policy")}
|
||||
prompt={t("Inherit from organization default")}
|
||||
options={Enum.map(@escalation_policies, &{&1.name, &1.id})}
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"Select an escalation policy for alert notifications. Leave empty to use the organization default."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
83
lib/towerops_web/live/escalation_policy_live/form.ex
Normal file
83
lib/towerops_web/live/escalation_policy_live/form.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule ToweropsWeb.EscalationPolicyLive.Form do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :active_page, "schedules")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
case socket.assigns.live_action do
|
||||
:new ->
|
||||
policy = %EscalationPolicy{}
|
||||
changeset = EscalationPolicy.changeset(policy, %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, t("New Escalation Policy"))
|
||||
|> assign(:policy, policy)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
|
||||
:edit ->
|
||||
policy = OnCall.get_escalation_policy!(params["id"])
|
||||
changeset = EscalationPolicy.changeset(policy, %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, t("Edit Escalation Policy"))
|
||||
|> assign(:policy, policy)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"escalation_policy" => params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.policy
|
||||
|> EscalationPolicy.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"escalation_policy" => params}, socket) do
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
params = Map.put(params, "organization_id", org_id)
|
||||
|
||||
case socket.assigns.live_action do
|
||||
:new -> save_policy(socket, :create, params)
|
||||
:edit -> save_policy(socket, :update, params)
|
||||
end
|
||||
end
|
||||
|
||||
defp save_policy(socket, :create, params) do
|
||||
case OnCall.create_escalation_policy(params) do
|
||||
{:ok, policy} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("Escalation policy created"))
|
||||
|> push_navigate(to: ~p"/schedules/escalation-policies/#{policy.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_policy(socket, :update, params) do
|
||||
case OnCall.update_escalation_policy(socket.assigns.policy, params) do
|
||||
{:ok, policy} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("Escalation policy updated"))
|
||||
|> push_navigate(to: ~p"/schedules/escalation-policies/#{policy.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
46
lib/towerops_web/live/escalation_policy_live/form.html.heex
Normal file
46
lib/towerops_web/live/escalation_policy_live/form.html.heex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<.link
|
||||
navigate={~p"/schedules?tab=escalation-policies"}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
|
||||
<div>
|
||||
<.input field={@form[:name]} type="text" label={t("Name")} required />
|
||||
</div>
|
||||
<div>
|
||||
<.input field={@form[:description]} type="textarea" label={t("Description")} />
|
||||
</div>
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:repeat_count]}
|
||||
type="number"
|
||||
label={t("Repeat Count")}
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<.button type="submit" phx-disable-with={t("Saving...")}>
|
||||
{t("Save Policy")}
|
||||
</.button>
|
||||
<.link
|
||||
navigate={~p"/schedules?tab=escalation-policies"}
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
18
lib/towerops_web/live/escalation_policy_live/index.ex
Normal file
18
lib/towerops_web/live/escalation_policy_live/index.ex
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
defmodule ToweropsWeb.EscalationPolicyLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
policies = OnCall.list_escalation_policies(org_id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, t("Escalation Policies"))
|
||||
|> assign(:active_page, "schedules")
|
||||
|> assign(:policies, policies)}
|
||||
end
|
||||
end
|
||||
71
lib/towerops_web/live/escalation_policy_live/index.html.heex
Normal file
71
lib/towerops_web/live/escalation_policy_live/index.html.heex
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<.link
|
||||
navigate={~p"/schedules"}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Escalation Policies")}</h1>
|
||||
</div>
|
||||
<.link
|
||||
navigate={~p"/schedules/escalation-policies/new"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("New Policy")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@policies) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<.icon
|
||||
name="hero-arrow-trending-up"
|
||||
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t("No escalation policies")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Create an escalation policy to define how alerts are escalated to on-call users.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Repeat Count")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for policy <- @policies do %>
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/schedules/escalation-policies/#{policy.id}")}
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{policy.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{policy.repeat_count}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
115
lib/towerops_web/live/escalation_policy_live/show.ex
Normal file
115
lib/towerops_web/live/escalation_policy_live/show.ex
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
defmodule ToweropsWeb.EscalationPolicyLive.Show do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.Organizations
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
policy = OnCall.get_escalation_policy!(id)
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
members = Organizations.list_organization_members(org_id)
|
||||
users = Enum.map(members, & &1.user)
|
||||
schedules = OnCall.list_schedules(org_id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, policy.name)
|
||||
|> assign(:active_page, "schedules")
|
||||
|> assign(:policy, policy)
|
||||
|> assign(:org_users, users)
|
||||
|> assign(:org_schedules, schedules)
|
||||
|> assign(:show_add_rule, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
case OnCall.delete_escalation_policy(socket.assigns.policy) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("Escalation policy deleted"))
|
||||
|> push_navigate(to: ~p"/schedules?tab=escalation-policies")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Unable to delete escalation policy"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_add_rule", _params, socket) do
|
||||
{:noreply, assign(socket, :show_add_rule, !socket.assigns.show_add_rule)}
|
||||
end
|
||||
|
||||
def handle_event("save_rule", %{"timeout_minutes" => timeout}, socket) do
|
||||
policy = socket.assigns.policy
|
||||
next_position = length(policy.rules)
|
||||
|
||||
case OnCall.create_escalation_rule(%{
|
||||
escalation_policy_id: policy.id,
|
||||
position: next_position,
|
||||
timeout_minutes: timeout
|
||||
}) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_add_rule, false)
|
||||
|> reload_policy()}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Unable to add rule"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_rule", %{"id" => rule_id}, socket) do
|
||||
rule = Enum.find(socket.assigns.policy.rules, &(&1.id == rule_id))
|
||||
|
||||
if rule do
|
||||
case OnCall.delete_escalation_rule(rule) do
|
||||
{:ok, _} -> {:noreply, reload_policy(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete rule"))}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("add_target", %{"rule_id" => rule_id, "target_type" => target_type} = params, socket) do
|
||||
attrs = %{
|
||||
escalation_rule_id: rule_id,
|
||||
target_type: target_type
|
||||
}
|
||||
|
||||
attrs =
|
||||
case target_type do
|
||||
"user" -> Map.put(attrs, :user_id, params["target_id"])
|
||||
"schedule" -> Map.put(attrs, :schedule_id, params["target_id"])
|
||||
end
|
||||
|
||||
case OnCall.create_escalation_target(attrs) do
|
||||
{:ok, _} -> {:noreply, reload_policy(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to add target"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_target", %{"id" => target_id}, socket) do
|
||||
target =
|
||||
socket.assigns.policy.rules
|
||||
|> Enum.flat_map(& &1.targets)
|
||||
|> Enum.find(&(&1.id == target_id))
|
||||
|
||||
if target do
|
||||
case OnCall.delete_escalation_target(target) do
|
||||
{:ok, _} -> {:noreply, reload_policy(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete target"))}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_policy(socket) do
|
||||
policy = OnCall.get_escalation_policy!(socket.assigns.policy.id)
|
||||
assign(socket, :policy, policy)
|
||||
end
|
||||
end
|
||||
197
lib/towerops_web/live/escalation_policy_live/show.html.heex
Normal file
197
lib/towerops_web/live/escalation_policy_live/show.html.heex
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<.link
|
||||
navigate={~p"/schedules?tab=escalation-policies"}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@policy.name}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Repeats %{count} times", count: @policy.repeat_count)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/schedules/escalation-policies/#{@policy.id}/edit"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-pencil" class="h-4 w-4" />
|
||||
{t("Edit")}
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete"
|
||||
data-confirm={t("Are you sure you want to delete this escalation policy?")}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @policy.description do %>
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@policy.description}</p>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t("Escalation Rules")}
|
||||
</h2>
|
||||
<button
|
||||
phx-click="toggle_add_rule"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("Add Rule")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @show_add_rule do %>
|
||||
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
|
||||
<form phx-submit="save_rule">
|
||||
<div class="flex items-end gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Escalate after (minutes)")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="timeout_minutes"
|
||||
value="30"
|
||||
min="1"
|
||||
max="1440"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<.button type="submit" phx-disable-with={t("Saving...")}>
|
||||
{t("Add Rule")}
|
||||
</.button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_add_rule"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@policy.rules) do %>
|
||||
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No rules configured. Add a rule to define escalation steps.")}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<%= for rule <- Enum.sort_by(@policy.rules, & &1.position) do %>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Step %{position}", position: rule.position + 1)}
|
||||
</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)}
|
||||
</span>
|
||||
<button
|
||||
phx-click="delete_rule"
|
||||
phx-value-id={rule.id}
|
||||
data-confirm={t("Are you sure you want to delete this rule?")}
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Targets --%>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t("Targets")}
|
||||
</p>
|
||||
<%= if Enum.empty?(rule.targets) do %>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
|
||||
{t("No targets yet")}
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<%= for target <- rule.targets do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
<%= case target.target_type do %>
|
||||
<% "user" -> %>
|
||||
<.icon name="hero-user" class="h-3 w-3 mr-0.5" />
|
||||
<% user = Enum.find(@org_users, &(&1.id == target.user_id)) %>
|
||||
{if user, do: user.email, else: t("Unknown user")}
|
||||
<% "schedule" -> %>
|
||||
<.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" />
|
||||
<% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %>
|
||||
{if sched, do: sched.name, else: t("Unknown schedule")}
|
||||
<% end %>
|
||||
<button
|
||||
phx-click="delete_target"
|
||||
phx-value-id={target.id}
|
||||
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Add target form --%>
|
||||
<form phx-submit="add_target" class="flex items-center gap-2">
|
||||
<input type="hidden" name="rule_id" value={rule.id} />
|
||||
<select
|
||||
name="target_type"
|
||||
id={"target-type-#{rule.id}"}
|
||||
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="user">{t("User")}</option>
|
||||
<option value="schedule">{t("Schedule")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="target_id"
|
||||
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="">{t("Select...")}</option>
|
||||
<optgroup label={t("Users")}>
|
||||
<%= for user <- @org_users do %>
|
||||
<option value={user.id}>{user.email}</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<optgroup label={t("Schedules")}>
|
||||
<%= for sched <- @org_schedules do %>
|
||||
<option value={sched.id}>{sched.name}</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
|
||||
{t("Add")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
83
lib/towerops_web/live/schedule_live/form.ex
Normal file
83
lib/towerops_web/live/schedule_live/form.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule ToweropsWeb.ScheduleLive.Form do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.Schedule
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :active_page, "schedules")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
case socket.assigns.live_action do
|
||||
:new ->
|
||||
schedule = %Schedule{}
|
||||
changeset = Schedule.changeset(schedule, %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, t("New Schedule"))
|
||||
|> assign(:schedule, schedule)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
|
||||
:edit ->
|
||||
schedule = OnCall.get_schedule!(params["id"])
|
||||
changeset = Schedule.changeset(schedule, %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, t("Edit Schedule"))
|
||||
|> assign(:schedule, schedule)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"schedule" => params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.schedule
|
||||
|> Schedule.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"schedule" => params}, socket) do
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
params = Map.put(params, "organization_id", org_id)
|
||||
|
||||
case socket.assigns.live_action do
|
||||
:new -> save_schedule(socket, :create, params)
|
||||
:edit -> save_schedule(socket, :update, params)
|
||||
end
|
||||
end
|
||||
|
||||
defp save_schedule(socket, :create, params) do
|
||||
case OnCall.create_schedule(params) do
|
||||
{:ok, schedule} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("Schedule created"))
|
||||
|> push_navigate(to: ~p"/schedules/#{schedule.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_schedule(socket, :update, params) do
|
||||
case OnCall.update_schedule(socket.assigns.schedule, params) do
|
||||
{:ok, schedule} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("Schedule updated"))
|
||||
|> push_navigate(to: ~p"/schedules/#{schedule.id}")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
46
lib/towerops_web/live/schedule_live/form.html.heex
Normal file
46
lib/towerops_web/live/schedule_live/form.html.heex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<.link
|
||||
navigate={~p"/schedules"}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
|
||||
<div>
|
||||
<.input field={@form[:name]} type="text" label={t("Name")} required />
|
||||
</div>
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:timezone]}
|
||||
type="text"
|
||||
label={t("Timezone")}
|
||||
placeholder="America/Chicago"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<.input field={@form[:description]} type="textarea" label={t("Description")} />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<.button type="submit" phx-disable-with={t("Saving...")}>
|
||||
{t("Save Schedule")}
|
||||
</.button>
|
||||
<.link
|
||||
navigate={~p"/schedules"}
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
48
lib/towerops_web/live/schedule_live/index.ex
Normal file
48
lib/towerops_web/live/schedule_live/index.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule ToweropsWeb.ScheduleLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, t("Schedules"))
|
||||
|> assign(:active_page, "schedules")
|
||||
|> assign(:tab, "schedules")
|
||||
|> load_data(organization.id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
tab = Map.get(params, "tab", "schedules")
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab, tab)
|
||||
|> load_data(org_id)}
|
||||
end
|
||||
|
||||
defp load_data(socket, org_id) do
|
||||
case socket.assigns.tab do
|
||||
"escalation-policies" ->
|
||||
policies = OnCall.list_escalation_policies(org_id)
|
||||
assign(socket, :policies, policies)
|
||||
|
||||
_ ->
|
||||
schedules = OnCall.list_schedules(org_id)
|
||||
|
||||
schedules_with_on_call =
|
||||
Enum.map(schedules, fn schedule ->
|
||||
on_call = OnCall.who_is_on_call(schedule.id)
|
||||
Map.put(schedule, :current_on_call, on_call)
|
||||
end)
|
||||
|
||||
assign(socket, :schedules, schedules_with_on_call)
|
||||
end
|
||||
end
|
||||
end
|
||||
168
lib/towerops_web/live/schedule_live/index.html.heex
Normal file
168
lib/towerops_web/live/schedule_live/index.html.heex
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Schedules")}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= if @tab == "escalation-policies" do %>
|
||||
<.link
|
||||
navigate={~p"/schedules/escalation-policies/new"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("New Policy")}
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={~p"/schedules/new"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("New Schedule")}
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<.link
|
||||
patch={~p"/schedules?tab=schedules"}
|
||||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@tab == "schedules" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@tab != "schedules" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Schedules")}
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/schedules?tab=escalation-policies"}
|
||||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@tab == "escalation-policies" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@tab != "escalation-policies" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Escalation Policies")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if @tab == "escalation-policies" do %>
|
||||
<%= if Enum.empty?(@policies) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<.icon
|
||||
name="hero-arrow-trending-up"
|
||||
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t("No escalation policies")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Create an escalation policy to define how alerts are escalated to on-call users.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Rules")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Repeat Count")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for policy <- @policies do %>
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/schedules/escalation-policies/#{policy.id}")}
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{policy.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
—
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{policy.repeat_count}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if Enum.empty?(@schedules) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<.icon
|
||||
name="hero-calendar-days"
|
||||
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t("No schedules")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Create an on-call schedule to define rotation coverage.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Timezone")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Currently On-Call")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for schedule <- @schedules do %>
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/schedules/#{schedule.id}")}
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{schedule.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{schedule.timezone}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= if schedule.current_on_call do %>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
{schedule.current_on_call.email}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500">{t("No one")}</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
185
lib/towerops_web/live/schedule_live/show.ex
Normal file
185
lib/towerops_web/live/schedule_live/show.ex
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
defmodule ToweropsWeb.ScheduleLive.Show do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.Organizations
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
schedule = OnCall.get_schedule!(id)
|
||||
on_call = OnCall.who_is_on_call(schedule.id)
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
members = Organizations.list_organization_members(org_id)
|
||||
users = Enum.map(members, & &1.user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, schedule.name)
|
||||
|> assign(:active_page, "schedules")
|
||||
|> assign(:schedule, schedule)
|
||||
|> assign(:on_call, on_call)
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
||||
|> assign(:org_users, users)
|
||||
|> assign(:show_add_layer, false)
|
||||
|> assign(:show_add_override, false)
|
||||
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
|
||||
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
case OnCall.delete_schedule(socket.assigns.schedule) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("Schedule deleted"))
|
||||
|> push_navigate(to: ~p"/schedules")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Unable to delete schedule"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_add_layer", _params, socket) do
|
||||
{:noreply, assign(socket, :show_add_layer, !socket.assigns.show_add_layer)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_add_override", _params, socket) do
|
||||
{:noreply, assign(socket, :show_add_override, !socket.assigns.show_add_override)}
|
||||
end
|
||||
|
||||
def handle_event("validate_layer", %{"layer" => params}, socket) do
|
||||
changeset =
|
||||
%Layer{}
|
||||
|> Layer.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :layer_form, to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("save_layer", %{"layer" => params}, socket) do
|
||||
schedule = socket.assigns.schedule
|
||||
next_position = length(schedule.layers)
|
||||
|
||||
params =
|
||||
params
|
||||
|> Map.put("schedule_id", schedule.id)
|
||||
|> Map.put("position", next_position)
|
||||
|
||||
case OnCall.create_layer(params) do
|
||||
{:ok, _layer} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_add_layer, false)
|
||||
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
|
||||
|> reload_schedule()}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :layer_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_layer", %{"id" => layer_id}, socket) do
|
||||
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
|
||||
|
||||
if layer do
|
||||
case OnCall.delete_layer(layer) do
|
||||
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete layer"))}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("add_member", %{"layer_id" => layer_id, "user_id" => user_id}, socket) do
|
||||
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
|
||||
|
||||
if layer do
|
||||
next_position = length(layer.members)
|
||||
|
||||
case OnCall.add_layer_member(%{
|
||||
layer_id: layer_id,
|
||||
user_id: user_id,
|
||||
position: next_position
|
||||
}) do
|
||||
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to add member"))}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_member", %{"id" => member_id}, socket) do
|
||||
member =
|
||||
socket.assigns.schedule.layers
|
||||
|> Enum.flat_map(& &1.members)
|
||||
|> Enum.find(&(&1.id == member_id))
|
||||
|
||||
if member do
|
||||
case OnCall.remove_layer_member(member) do
|
||||
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to remove member"))}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_override", %{"override" => params}, socket) do
|
||||
changeset =
|
||||
%Override{}
|
||||
|> Override.changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :override_form, to_form(changeset))}
|
||||
end
|
||||
|
||||
def handle_event("save_override", %{"override" => params}, socket) do
|
||||
schedule = socket.assigns.schedule
|
||||
user_id = socket.assigns.current_scope.user.id
|
||||
|
||||
params =
|
||||
params
|
||||
|> Map.put("schedule_id", schedule.id)
|
||||
|> Map.put("created_by_id", user_id)
|
||||
|
||||
case OnCall.create_override(params) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_add_override, false)
|
||||
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))
|
||||
|> reload_schedule()}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :override_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_override", %{"id" => override_id}, socket) do
|
||||
override = Enum.find(socket.assigns.schedule.overrides, &(&1.id == override_id))
|
||||
|
||||
if override do
|
||||
case OnCall.delete_override(override) do
|
||||
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
||||
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete override"))}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_schedule(socket) do
|
||||
schedule = OnCall.get_schedule!(socket.assigns.schedule.id)
|
||||
on_call = OnCall.who_is_on_call(schedule.id)
|
||||
|
||||
socket
|
||||
|> assign(:schedule, schedule)
|
||||
|> assign(:on_call, on_call)
|
||||
end
|
||||
end
|
||||
342
lib/towerops_web/live/schedule_live/show.html.heex
Normal file
342
lib/towerops_web/live/schedule_live/show.html.heex
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<.link
|
||||
navigate={~p"/schedules"}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@schedule.name}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{@schedule.timezone}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/schedules/#{@schedule.id}/edit"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-pencil" class="h-4 w-4" />
|
||||
{t("Edit")}
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete"
|
||||
data-confirm={t("Are you sure you want to delete this schedule?")}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @schedule.description do %>
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@schedule.description}</p>
|
||||
<% end %>
|
||||
|
||||
<%!-- On-Call Status --%>
|
||||
<div class="mb-8 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={[
|
||||
"h-10 w-10 rounded-full flex items-center justify-center",
|
||||
@on_call && "bg-green-100 dark:bg-green-900/40",
|
||||
!@on_call && "bg-gray-100 dark:bg-gray-800"
|
||||
]}>
|
||||
<.icon
|
||||
name="hero-user"
|
||||
class={[
|
||||
"h-5 w-5",
|
||||
@on_call && "text-green-600 dark:text-green-400",
|
||||
!@on_call && "text-gray-400 dark:text-gray-500"
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Currently On-Call")}
|
||||
</p>
|
||||
<%= if @on_call do %>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{@on_call.email}</p>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No one is currently on-call")}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Layers --%>
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Layers")}</h2>
|
||||
<button
|
||||
phx-click="toggle_add_layer"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("Add Layer")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @show_add_layer do %>
|
||||
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
|
||||
<.form for={@layer_form} phx-change="validate_layer" phx-submit="save_layer">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<.input
|
||||
field={@layer_form[:name]}
|
||||
type="text"
|
||||
label={t("Layer Name")}
|
||||
placeholder="Layer 1"
|
||||
/>
|
||||
<.input
|
||||
field={@layer_form[:rotation_type]}
|
||||
type="select"
|
||||
label={t("Rotation Type")}
|
||||
options={[{t("Daily"), "daily"}, {t("Weekly"), "weekly"}, {t("Custom"), "custom"}]}
|
||||
/>
|
||||
<.input
|
||||
field={@layer_form[:rotation_interval]}
|
||||
type="number"
|
||||
label={t("Rotation Interval")}
|
||||
min="1"
|
||||
/>
|
||||
<.input field={@layer_form[:handoff_time]} type="time" label={t("Handoff Time")} />
|
||||
<.input
|
||||
field={@layer_form[:handoff_day]}
|
||||
type="select"
|
||||
label={t("Handoff Day")}
|
||||
options={[
|
||||
{t("Sunday"), "0"},
|
||||
{t("Monday"), "1"},
|
||||
{t("Tuesday"), "2"},
|
||||
{t("Wednesday"), "3"},
|
||||
{t("Thursday"), "4"},
|
||||
{t("Friday"), "5"},
|
||||
{t("Saturday"), "6"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@layer_form[:start_date]}
|
||||
type="datetime-local"
|
||||
label={t("Start Date")}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<.button type="submit" phx-disable-with={t("Saving...")}>
|
||||
{t("Add Layer")}
|
||||
</.button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_add_layer"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@schedule.layers) do %>
|
||||
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No layers configured. Add a layer to define rotation coverage.")}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<%= for layer <- Enum.sort_by(@schedule.layers, & &1.position) do %>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{layer.name}</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval}
|
||||
<%= case layer.rotation_type do %>
|
||||
<% "daily" -> %>
|
||||
{ngettext("day", "days", layer.rotation_interval)}
|
||||
<% "weekly" -> %>
|
||||
{ngettext("week", "weeks", layer.rotation_interval)}
|
||||
<% _ -> %>
|
||||
{ngettext("day", "days", layer.rotation_interval)}
|
||||
<% end %>
|
||||
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
phx-click="delete_layer"
|
||||
phx-value-id={layer.id}
|
||||
data-confirm={t("Are you sure you want to delete this layer?")}
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Members --%>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t("Members")}
|
||||
</p>
|
||||
<%= if Enum.empty?(layer.members) do %>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
|
||||
{t("No members yet")}
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<%= for member <- Enum.sort_by(layer.members, & &1.position) do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{member.user.email}
|
||||
<button
|
||||
phx-click="remove_member"
|
||||
phx-value-id={member.id}
|
||||
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Add member dropdown --%>
|
||||
<% existing_user_ids = Enum.map(layer.members, & &1.user_id) %>
|
||||
<% available_users = Enum.reject(@org_users, &(&1.id in existing_user_ids)) %>
|
||||
<%= unless Enum.empty?(available_users) do %>
|
||||
<form phx-submit="add_member" class="flex items-center gap-2">
|
||||
<input type="hidden" name="layer_id" value={layer.id} />
|
||||
<select
|
||||
name="user_id"
|
||||
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="">{t("Select a member...")}</option>
|
||||
<%= for user <- available_users do %>
|
||||
<option value={user.id}>{user.email}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
|
||||
{t("Add")}
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Overrides --%>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Overrides")}</h2>
|
||||
<button
|
||||
phx-click="toggle_add_override"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
{t("Add Override")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @show_add_override do %>
|
||||
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
|
||||
<.form for={@override_form} phx-change="validate_override" phx-submit="save_override">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<.input
|
||||
field={@override_form[:user_id]}
|
||||
type="select"
|
||||
label={t("User")}
|
||||
options={Enum.map(@org_users, &{&1.email, &1.id})}
|
||||
prompt={t("Select user...")}
|
||||
/>
|
||||
<.input
|
||||
field={@override_form[:start_time]}
|
||||
type="datetime-local"
|
||||
label={t("Start Time")}
|
||||
/>
|
||||
<.input
|
||||
field={@override_form[:end_time]}
|
||||
type="datetime-local"
|
||||
label={t("End Time")}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<.button type="submit" phx-disable-with={t("Saving...")}>
|
||||
{t("Add Override")}
|
||||
</.button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_add_override"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@schedule.overrides) do %>
|
||||
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No overrides configured.")}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("User")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Start")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("End")}
|
||||
</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for override <- @schedule.overrides do %>
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
{override.user.email}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{Calendar.strftime(override.end_time, "%Y-%m-%d %H:%M")}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
phx-click="delete_override"
|
||||
phx-value-id={override.id}
|
||||
data-confirm={t("Are you sure?")}
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
@ -435,6 +435,16 @@ defmodule ToweropsWeb.Router do
|
|||
# Activity feed
|
||||
live "/activity", ActivityFeedLive, :index
|
||||
|
||||
# On-call schedules
|
||||
live "/schedules", ScheduleLive.Index, :index
|
||||
live "/schedules/new", ScheduleLive.Form, :new
|
||||
live "/schedules/escalation-policies", EscalationPolicyLive.Index, :index
|
||||
live "/schedules/escalation-policies/new", EscalationPolicyLive.Form, :new
|
||||
live "/schedules/escalation-policies/:id", EscalationPolicyLive.Show, :show
|
||||
live "/schedules/escalation-policies/:id/edit", EscalationPolicyLive.Form, :edit
|
||||
live "/schedules/:id", ScheduleLive.Show, :show
|
||||
live "/schedules/:id/edit", ScheduleLive.Form, :edit
|
||||
|
||||
# Maintenance windows
|
||||
live "/maintenance", MaintenanceLive.Index, :index
|
||||
live "/maintenance/new", MaintenanceLive.Form, :new
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateOnCallSchedules do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:on_call_schedules, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :name, :string, null: false
|
||||
add :description, :string
|
||||
add :timezone, :string, null: false
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:on_call_schedules, [:organization_id])
|
||||
|
||||
create table(:on_call_layers, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :name, :string, null: false
|
||||
add :position, :integer, null: false, default: 0
|
||||
add :rotation_type, :string, null: false
|
||||
add :rotation_interval, :integer, null: false, default: 1
|
||||
add :handoff_time, :time, null: false
|
||||
add :handoff_day, :integer
|
||||
add :start_date, :utc_datetime, null: false
|
||||
add :restriction_type, :string
|
||||
add :restrictions, :map
|
||||
|
||||
add :schedule_id, references(:on_call_schedules, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:on_call_layers, [:schedule_id])
|
||||
|
||||
create table(:on_call_layer_members, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :position, :integer, null: false, default: 0
|
||||
|
||||
add :layer_id, references(:on_call_layers, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:on_call_layer_members, [:layer_id])
|
||||
create unique_index(:on_call_layer_members, [:layer_id, :user_id])
|
||||
|
||||
create table(:on_call_overrides, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :start_time, :utc_datetime, null: false
|
||||
add :end_time, :utc_datetime, null: false
|
||||
|
||||
add :schedule_id, references(:on_call_schedules, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:on_call_overrides, [:schedule_id, :start_time, :end_time])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateEscalationPolicies do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:escalation_policies, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :name, :string, null: false
|
||||
add :description, :string
|
||||
add :repeat_count, :integer, null: false, default: 3
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:escalation_policies, [:organization_id])
|
||||
|
||||
create table(:escalation_rules, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :position, :integer, null: false, default: 0
|
||||
add :timeout_minutes, :integer, null: false, default: 30
|
||||
|
||||
add :escalation_policy_id,
|
||||
references(:escalation_policies, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:escalation_rules, [:escalation_policy_id])
|
||||
|
||||
create table(:escalation_targets, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :target_type, :string, null: false
|
||||
|
||||
add :escalation_rule_id,
|
||||
references(:escalation_rules, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :schedule_id, references(:on_call_schedules, type: :binary_id, on_delete: :delete_all)
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:escalation_targets, [:escalation_rule_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateOnCallIncidentsAndNotifications do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:on_call_incidents, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :status, :string, null: false, default: "triggered"
|
||||
add :current_rule_position, :integer, null: false, default: 0
|
||||
add :current_loop, :integer, null: false, default: 1
|
||||
add :triggered_at, :utc_datetime, null: false
|
||||
add :acknowledged_at, :utc_datetime
|
||||
add :resolved_at, :utc_datetime
|
||||
|
||||
add :alert_id, references(:alerts, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :escalation_policy_id,
|
||||
references(:escalation_policies, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :acknowledged_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
add :resolved_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:on_call_incidents, [:alert_id])
|
||||
create index(:on_call_incidents, [:organization_id, :status])
|
||||
|
||||
create table(:on_call_notifications, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :channel, :string, null: false, default: "email"
|
||||
add :sent_at, :utc_datetime
|
||||
add :status, :string, null: false, default: "sent"
|
||||
add :error_message, :string
|
||||
|
||||
add :incident_id,
|
||||
references(:on_call_incidents, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:on_call_notifications, [:incident_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Towerops.Repo.Migrations.AddEscalationPolicyToDevicesAndOrganizations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:devices) do
|
||||
add :escalation_policy_id,
|
||||
references(:escalation_policies, type: :binary_id, on_delete: :nilify_all)
|
||||
end
|
||||
|
||||
alter table(:organizations) do
|
||||
add :default_escalation_policy_id,
|
||||
references(:escalation_policies, type: :binary_id, on_delete: :nilify_all)
|
||||
end
|
||||
end
|
||||
end
|
||||
110
test/support/fixtures/on_call_fixtures.ex
Normal file
110
test/support/fixtures/on_call_fixtures.ex
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
defmodule Towerops.OnCallFixtures do
|
||||
@moduledoc """
|
||||
Test helpers for creating on-call schedule and escalation policy entities.
|
||||
"""
|
||||
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
alias Towerops.OnCall.EscalationRule
|
||||
alias Towerops.OnCall.EscalationTarget
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.LayerMember
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Schedule
|
||||
alias Towerops.Repo
|
||||
|
||||
def schedule_fixture(organization_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
name: "Schedule #{System.unique_integer([:positive])}",
|
||||
timezone: "America/Chicago",
|
||||
organization_id: organization_id
|
||||
})
|
||||
|
||||
%Schedule{}
|
||||
|> Schedule.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def layer_fixture(schedule_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
name: "Layer #{System.unique_integer([:positive])}",
|
||||
position: 1,
|
||||
rotation_type: "weekly",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
handoff_day: 1,
|
||||
start_date: ~U[2026-01-01 09:00:00Z],
|
||||
schedule_id: schedule_id
|
||||
})
|
||||
|
||||
%Layer{}
|
||||
|> Layer.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def layer_member_fixture(layer_id, user_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
position: 0,
|
||||
layer_id: layer_id,
|
||||
user_id: user_id
|
||||
})
|
||||
|
||||
%LayerMember{}
|
||||
|> LayerMember.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def override_fixture(schedule_id, user_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
start_time: ~U[2026-03-15 09:00:00Z],
|
||||
end_time: ~U[2026-03-16 09:00:00Z],
|
||||
schedule_id: schedule_id,
|
||||
user_id: user_id
|
||||
})
|
||||
|
||||
%Override{}
|
||||
|> Override.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def escalation_policy_fixture(organization_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
name: "Policy #{System.unique_integer([:positive])}",
|
||||
repeat_count: 3,
|
||||
organization_id: organization_id
|
||||
})
|
||||
|
||||
%EscalationPolicy{}
|
||||
|> EscalationPolicy.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def escalation_rule_fixture(escalation_policy_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
position: 0,
|
||||
timeout_minutes: 30,
|
||||
escalation_policy_id: escalation_policy_id
|
||||
})
|
||||
|
||||
%EscalationRule{}
|
||||
|> EscalationRule.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def escalation_target_fixture(escalation_rule_id, attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
target_type: "user",
|
||||
escalation_rule_id: escalation_rule_id
|
||||
})
|
||||
|
||||
%EscalationTarget{}
|
||||
|> EscalationTarget.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
end
|
||||
149
test/towerops/on_call/escalation_test.exs
Normal file
149
test/towerops/on_call/escalation_test.exs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
defmodule Towerops.OnCall.EscalationTest do
|
||||
use Towerops.DataCase, async: true
|
||||
use Oban.Testing, repo: Towerops.Repo
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OnCallFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.OnCall.Escalation
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
site = Towerops.OrganizationsFixtures.site_fixture(organization.id)
|
||||
device = device_fixture(organization.id, site.id)
|
||||
|
||||
%{user: user, organization: organization, device: device}
|
||||
end
|
||||
|
||||
describe "start_escalation/2" do
|
||||
test "creates incident and enqueues escalation check", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user.id, %{position: 0})
|
||||
|
||||
policy = escalation_policy_fixture(ctx.organization.id)
|
||||
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
|
||||
escalation_target_fixture(rule.id, %{target_type: "schedule", schedule_id: schedule.id})
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
alert_type: "device_down",
|
||||
device_id: ctx.device.id,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
assert {:ok, incident} = Escalation.start_escalation(alert, policy.id)
|
||||
assert incident.status == "triggered"
|
||||
assert incident.alert_id == alert.id
|
||||
assert incident.escalation_policy_id == policy.id
|
||||
assert incident.current_rule_position == 0
|
||||
assert incident.current_loop == 1
|
||||
|
||||
# Should have enqueued an escalation check worker
|
||||
assert_enqueued(
|
||||
worker: Towerops.Workers.EscalationCheckWorker,
|
||||
args: %{incident_id: incident.id}
|
||||
)
|
||||
end
|
||||
|
||||
test "returns :no_policy when policy doesn't exist", ctx do
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
alert_type: "device_down",
|
||||
device_id: ctx.device.id,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
assert {:error, :no_policy} = Escalation.start_escalation(alert, nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe "acknowledge_incident/2" do
|
||||
test "marks incident as acknowledged", ctx do
|
||||
policy = escalation_policy_fixture(ctx.organization.id)
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
alert_type: "device_down",
|
||||
device_id: ctx.device.id,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
|
||||
escalation_target_fixture(rule.id, %{target_type: "user", user_id: ctx.user.id})
|
||||
|
||||
{:ok, incident} = Escalation.start_escalation(alert, policy.id)
|
||||
|
||||
assert {:ok, updated} = Escalation.acknowledge_incident(incident.id, ctx.user.id)
|
||||
assert updated.status == "acknowledged"
|
||||
assert updated.acknowledged_by_id == ctx.user.id
|
||||
assert updated.acknowledged_at
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve_incident/2" do
|
||||
test "marks incident as resolved", ctx do
|
||||
policy = escalation_policy_fixture(ctx.organization.id)
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
alert_type: "device_down",
|
||||
device_id: ctx.device.id,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
|
||||
escalation_target_fixture(rule.id, %{target_type: "user", user_id: ctx.user.id})
|
||||
|
||||
{:ok, incident} = Escalation.start_escalation(alert, policy.id)
|
||||
|
||||
assert {:ok, updated} = Escalation.resolve_incident(incident.id, ctx.user.id)
|
||||
assert updated.status == "resolved"
|
||||
assert updated.resolved_by_id == ctx.user.id
|
||||
assert updated.resolved_at
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_incident_for_alert/1" do
|
||||
test "finds active incident for alert", ctx do
|
||||
policy = escalation_policy_fixture(ctx.organization.id)
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
alert_type: "device_down",
|
||||
device_id: ctx.device.id,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 5})
|
||||
escalation_target_fixture(rule.id, %{target_type: "user", user_id: ctx.user.id})
|
||||
|
||||
{:ok, incident} = Escalation.start_escalation(alert, policy.id)
|
||||
|
||||
found = Escalation.find_incident_for_alert(alert.id)
|
||||
assert found.id == incident.id
|
||||
end
|
||||
|
||||
test "returns nil when no active incident exists", _ctx do
|
||||
assert Escalation.find_incident_for_alert(Ecto.UUID.generate()) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
308
test/towerops/on_call/resolver_test.exs
Normal file
308
test/towerops/on_call/resolver_test.exs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
defmodule Towerops.OnCall.ResolverTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OnCallFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.OnCall.Resolver
|
||||
|
||||
setup do
|
||||
user1 = user_fixture(%{email: "oncall1@example.com"})
|
||||
user2 = user_fixture(%{email: "oncall2@example.com"})
|
||||
user3 = user_fixture(%{email: "oncall3@example.com"})
|
||||
organization = organization_fixture(user1.id)
|
||||
|
||||
%{user1: user1, user2: user2, user3: user3, organization: organization}
|
||||
end
|
||||
|
||||
describe "resolve/2 with daily rotation" do
|
||||
test "returns first user on start date", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
result = Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z])
|
||||
assert result.id == ctx.user1.id
|
||||
end
|
||||
|
||||
test "rotates to second user after one day", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
result = Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z])
|
||||
assert result.id == ctx.user2.id
|
||||
end
|
||||
|
||||
test "wraps around after all users have rotated", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# Day 3 = position 0 again (wraps)
|
||||
result = Resolver.resolve(schedule, ~U[2026-03-03 12:00:00Z])
|
||||
assert result.id == ctx.user1.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/2 with weekly rotation" do
|
||||
test "rotates weekly", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "weekly",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
handoff_day: 1,
|
||||
start_date: ~U[2026-03-02 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
|
||||
layer_member_fixture(layer.id, ctx.user3.id, %{position: 2})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# Week 1
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-03 12:00:00Z]).id == ctx.user1.id
|
||||
# Week 2
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-10 12:00:00Z]).id == ctx.user2.id
|
||||
# Week 3
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-17 12:00:00Z]).id == ctx.user3.id
|
||||
# Week 4 wraps
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-24 12:00:00Z]).id == ctx.user1.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/2 with custom rotation" do
|
||||
test "supports multi-day custom interval", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "custom",
|
||||
rotation_interval: 3,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# Days 1-3: user1
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]).id == ctx.user1.id
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-03 12:00:00Z]).id == ctx.user1.id
|
||||
# Days 4-6: user2
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-04 12:00:00Z]).id == ctx.user2.id
|
||||
# Days 7-9: wraps to user1
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-07 12:00:00Z]).id == ctx.user1.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/2 with layer stacking" do
|
||||
test "higher position layer overrides lower", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
# Layer 1 (lower priority): user1 every day
|
||||
layer1 =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Base",
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer1.id, ctx.user1.id, %{position: 0})
|
||||
|
||||
# Layer 2 (higher priority): user2 every day
|
||||
layer2 =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Override Layer",
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 2
|
||||
})
|
||||
|
||||
layer_member_fixture(layer2.id, ctx.user2.id, %{position: 0})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# Higher priority layer wins
|
||||
result = Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z])
|
||||
assert result.id == ctx.user2.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/2 with time-of-day restriction" do
|
||||
test "layer only active during restricted hours", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
# Base layer: user1 always on-call
|
||||
base =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Base",
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[00:00:00],
|
||||
start_date: ~U[2026-03-01 00:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(base.id, ctx.user1.id, %{position: 0})
|
||||
|
||||
# Restricted layer: user2 only 18:00-08:00 (after hours)
|
||||
restricted =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "After Hours",
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[18:00:00],
|
||||
start_date: ~U[2026-03-01 18:00:00Z],
|
||||
position: 2,
|
||||
restriction_type: "time_of_day",
|
||||
restrictions: %{"start_time" => "18:00", "end_time" => "08:00"}
|
||||
})
|
||||
|
||||
layer_member_fixture(restricted.id, ctx.user2.id, %{position: 0})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# During business hours: base layer (user1)
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-01 14:00:00Z]).id == ctx.user1.id
|
||||
|
||||
# After hours: restricted layer overrides (user2)
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-01 20:00:00Z]).id == ctx.user2.id
|
||||
|
||||
# Early morning (still after hours): restricted layer (user2)
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-02 05:00:00Z]).id == ctx.user2.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/2 with overrides" do
|
||||
test "override takes precedence over all layers", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
|
||||
# Override: user3 from March 5-6
|
||||
override_fixture(schedule.id, ctx.user3.id, %{
|
||||
start_time: ~U[2026-03-05 09:00:00Z],
|
||||
end_time: ~U[2026-03-06 09:00:00Z],
|
||||
created_by_id: ctx.user1.id
|
||||
})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# Before override: normal rotation
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-04 12:00:00Z]).id == ctx.user1.id
|
||||
|
||||
# During override: user3
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-05 12:00:00Z]).id == ctx.user3.id
|
||||
|
||||
# After override: normal rotation
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-07 12:00:00Z]).id == ctx.user1.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve/2 edge cases" do
|
||||
test "returns nil for schedule with no layers", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]) == nil
|
||||
end
|
||||
|
||||
test "returns nil for layer with no members", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]) == nil
|
||||
end
|
||||
|
||||
test "returns nil for datetime before layer start_date", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-10 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
assert Resolver.resolve(schedule, ~U[2026-03-01 12:00:00Z]) == nil
|
||||
end
|
||||
end
|
||||
|
||||
defp preload_schedule(schedule) do
|
||||
Towerops.Repo.preload(schedule, overrides: :user, layers: [members: :user])
|
||||
end
|
||||
end
|
||||
249
test/towerops/on_call/schema_test.exs
Normal file
249
test/towerops/on_call/schema_test.exs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
defmodule Towerops.OnCall.SchemaTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OnCallFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.OnCall.EscalationPolicy
|
||||
alias Towerops.OnCall.EscalationTarget
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Schedule
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{user: user, organization: organization}
|
||||
end
|
||||
|
||||
describe "Schedule" do
|
||||
test "valid changeset with required fields", %{organization: org} do
|
||||
changeset =
|
||||
Schedule.changeset(%Schedule{}, %{
|
||||
name: "Primary On-Call",
|
||||
timezone: "America/Chicago",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without name", %{organization: org} do
|
||||
changeset =
|
||||
Schedule.changeset(%Schedule{}, %{
|
||||
timezone: "America/Chicago",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "invalid without timezone", %{organization: org} do
|
||||
changeset =
|
||||
Schedule.changeset(%Schedule{}, %{
|
||||
name: "Primary On-Call",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).timezone
|
||||
end
|
||||
|
||||
test "persists to database", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
assert schedule.id
|
||||
assert schedule.name
|
||||
assert schedule.timezone == "America/Chicago"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Layer" do
|
||||
test "valid changeset with required fields", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
changeset =
|
||||
Layer.changeset(%Layer{}, %{
|
||||
name: "Layer 1",
|
||||
position: 1,
|
||||
rotation_type: "weekly",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
handoff_day: 1,
|
||||
start_date: ~U[2026-01-01 09:00:00Z],
|
||||
schedule_id: schedule.id
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid rotation_type rejected", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
changeset =
|
||||
Layer.changeset(%Layer{}, %{
|
||||
name: "Layer 1",
|
||||
position: 1,
|
||||
rotation_type: "invalid",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z],
|
||||
schedule_id: schedule.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).rotation_type
|
||||
end
|
||||
|
||||
test "persists to database", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
assert layer.id
|
||||
assert layer.rotation_type == "weekly"
|
||||
end
|
||||
end
|
||||
|
||||
describe "LayerMember" do
|
||||
test "persists to database", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
member = layer_member_fixture(layer.id, user.id, %{position: 0})
|
||||
assert member.id
|
||||
assert member.position == 0
|
||||
end
|
||||
|
||||
test "enforces unique user per layer", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
layer_member_fixture(layer.id, user.id, %{position: 0})
|
||||
|
||||
assert_raise Ecto.InvalidChangesetError, fn ->
|
||||
layer_member_fixture(layer.id, user.id, %{position: 1})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Override" do
|
||||
test "valid changeset", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
changeset =
|
||||
Override.changeset(%Override{}, %{
|
||||
start_time: ~U[2026-03-15 09:00:00Z],
|
||||
end_time: ~U[2026-03-16 09:00:00Z],
|
||||
schedule_id: schedule.id,
|
||||
user_id: user.id,
|
||||
created_by_id: user.id
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid when end_time before start_time", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
changeset =
|
||||
Override.changeset(%Override{}, %{
|
||||
start_time: ~U[2026-03-16 09:00:00Z],
|
||||
end_time: ~U[2026-03-15 09:00:00Z],
|
||||
schedule_id: schedule.id,
|
||||
user_id: user.id,
|
||||
created_by_id: user.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).end_time
|
||||
end
|
||||
|
||||
test "persists to database", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
override = override_fixture(schedule.id, user.id, %{created_by_id: user.id})
|
||||
assert override.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "EscalationPolicy" do
|
||||
test "valid changeset", %{organization: org} do
|
||||
changeset =
|
||||
EscalationPolicy.changeset(%EscalationPolicy{}, %{
|
||||
name: "Default Policy",
|
||||
repeat_count: 3,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without name", %{organization: org} do
|
||||
changeset =
|
||||
EscalationPolicy.changeset(%EscalationPolicy{}, %{
|
||||
repeat_count: 3,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "persists to database", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
assert policy.id
|
||||
assert policy.repeat_count == 3
|
||||
end
|
||||
end
|
||||
|
||||
describe "EscalationRule" do
|
||||
test "persists to database", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
rule = escalation_rule_fixture(policy.id, %{position: 0, timeout_minutes: 15})
|
||||
assert rule.id
|
||||
assert rule.timeout_minutes == 15
|
||||
end
|
||||
end
|
||||
|
||||
describe "EscalationTarget" do
|
||||
test "target with schedule type", %{user: _user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
target =
|
||||
escalation_target_fixture(rule.id, %{
|
||||
target_type: "schedule",
|
||||
schedule_id: schedule.id
|
||||
})
|
||||
|
||||
assert target.target_type == "schedule"
|
||||
assert target.schedule_id == schedule.id
|
||||
end
|
||||
|
||||
test "target with user type", %{user: user, organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
target =
|
||||
escalation_target_fixture(rule.id, %{
|
||||
target_type: "user",
|
||||
user_id: user.id
|
||||
})
|
||||
|
||||
assert target.target_type == "user"
|
||||
assert target.user_id == user.id
|
||||
end
|
||||
|
||||
test "invalid target_type rejected", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
changeset =
|
||||
EscalationTarget.changeset(%EscalationTarget{}, %{
|
||||
target_type: "invalid",
|
||||
escalation_rule_id: rule.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).target_type
|
||||
end
|
||||
end
|
||||
end
|
||||
217
test/towerops/on_call_test.exs
Normal file
217
test/towerops/on_call_test.exs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
defmodule Towerops.OnCallTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OnCallFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.OnCall
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
organization = organization_fixture(user.id)
|
||||
%{user: user, organization: organization}
|
||||
end
|
||||
|
||||
describe "schedules" do
|
||||
test "list_schedules/1 returns schedules for org", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
assert [found] = OnCall.list_schedules(org.id)
|
||||
assert found.id == schedule.id
|
||||
end
|
||||
|
||||
test "get_schedule!/1 returns schedule with preloaded associations", %{
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
layer_member_fixture(layer.id, user.id)
|
||||
|
||||
found = OnCall.get_schedule!(schedule.id)
|
||||
assert found.id == schedule.id
|
||||
assert length(found.layers) == 1
|
||||
assert length(hd(found.layers).members) == 1
|
||||
end
|
||||
|
||||
test "create_schedule/1 with valid attrs", %{organization: org} do
|
||||
attrs = %{name: "Primary", timezone: "America/Chicago", organization_id: org.id}
|
||||
assert {:ok, schedule} = OnCall.create_schedule(attrs)
|
||||
assert schedule.name == "Primary"
|
||||
end
|
||||
|
||||
test "create_schedule/1 with invalid attrs returns error" do
|
||||
assert {:error, changeset} = OnCall.create_schedule(%{})
|
||||
assert errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "update_schedule/2", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
assert {:ok, updated} = OnCall.update_schedule(schedule, %{name: "Updated"})
|
||||
assert updated.name == "Updated"
|
||||
end
|
||||
|
||||
test "delete_schedule/1", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
assert {:ok, _} = OnCall.delete_schedule(schedule)
|
||||
assert_raise Ecto.NoResultsError, fn -> OnCall.get_schedule!(schedule.id) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "layers" do
|
||||
test "create_layer/1", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
attrs = %{
|
||||
name: "Layer 1",
|
||||
position: 1,
|
||||
rotation_type: "weekly",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
handoff_day: 1,
|
||||
start_date: ~U[2026-01-01 09:00:00Z],
|
||||
schedule_id: schedule.id
|
||||
}
|
||||
|
||||
assert {:ok, layer} = OnCall.create_layer(attrs)
|
||||
assert layer.name == "Layer 1"
|
||||
end
|
||||
|
||||
test "update_layer/2", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
assert {:ok, updated} = OnCall.update_layer(layer, %{name: "Updated Layer"})
|
||||
assert updated.name == "Updated Layer"
|
||||
end
|
||||
|
||||
test "delete_layer/1", %{organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
assert {:ok, _} = OnCall.delete_layer(layer)
|
||||
end
|
||||
end
|
||||
|
||||
describe "layer_members" do
|
||||
test "add_layer_member/1 and remove_layer_member/1", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
|
||||
assert {:ok, member} =
|
||||
OnCall.add_layer_member(%{layer_id: layer.id, user_id: user.id, position: 0})
|
||||
|
||||
assert member.user_id == user.id
|
||||
|
||||
assert {:ok, _} = OnCall.remove_layer_member(member)
|
||||
end
|
||||
end
|
||||
|
||||
describe "overrides" do
|
||||
test "create_override/1 and delete_override/1", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
attrs = %{
|
||||
start_time: ~U[2026-03-15 09:00:00Z],
|
||||
end_time: ~U[2026-03-16 09:00:00Z],
|
||||
schedule_id: schedule.id,
|
||||
user_id: user.id,
|
||||
created_by_id: user.id
|
||||
}
|
||||
|
||||
assert {:ok, override} = OnCall.create_override(attrs)
|
||||
assert override.user_id == user.id
|
||||
|
||||
assert {:ok, _} = OnCall.delete_override(override)
|
||||
end
|
||||
end
|
||||
|
||||
describe "escalation_policies" do
|
||||
test "list_escalation_policies/1", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
assert [found] = OnCall.list_escalation_policies(org.id)
|
||||
assert found.id == policy.id
|
||||
end
|
||||
|
||||
test "get_escalation_policy!/1 with preloaded rules and targets", %{
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
escalation_target_fixture(rule.id, %{target_type: "user", user_id: user.id})
|
||||
|
||||
found = OnCall.get_escalation_policy!(policy.id)
|
||||
assert found.id == policy.id
|
||||
assert length(found.rules) == 1
|
||||
assert length(hd(found.rules).targets) == 1
|
||||
end
|
||||
|
||||
test "create_escalation_policy/1", %{organization: org} do
|
||||
attrs = %{name: "Default", repeat_count: 3, organization_id: org.id}
|
||||
assert {:ok, policy} = OnCall.create_escalation_policy(attrs)
|
||||
assert policy.name == "Default"
|
||||
end
|
||||
|
||||
test "update_escalation_policy/2", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
assert {:ok, updated} = OnCall.update_escalation_policy(policy, %{name: "Updated"})
|
||||
assert updated.name == "Updated"
|
||||
end
|
||||
|
||||
test "delete_escalation_policy/1", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
assert {:ok, _} = OnCall.delete_escalation_policy(policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe "escalation_rules" do
|
||||
test "create_escalation_rule/1 and delete_escalation_rule/1", %{organization: org} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
|
||||
attrs = %{position: 0, timeout_minutes: 15, escalation_policy_id: policy.id}
|
||||
assert {:ok, rule} = OnCall.create_escalation_rule(attrs)
|
||||
assert rule.timeout_minutes == 15
|
||||
|
||||
assert {:ok, _} = OnCall.delete_escalation_rule(rule)
|
||||
end
|
||||
end
|
||||
|
||||
describe "escalation_targets" do
|
||||
test "create_escalation_target/1 and delete_escalation_target/1", %{
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
policy = escalation_policy_fixture(org.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
attrs = %{target_type: "user", user_id: user.id, escalation_rule_id: rule.id}
|
||||
assert {:ok, target} = OnCall.create_escalation_target(attrs)
|
||||
assert target.target_type == "user"
|
||||
|
||||
assert {:ok, _} = OnCall.delete_escalation_target(target)
|
||||
end
|
||||
end
|
||||
|
||||
describe "who_is_on_call/2" do
|
||||
test "resolves who is on call from schedule id", %{user: user, organization: org} do
|
||||
schedule = schedule_fixture(org.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, user.id, %{position: 0})
|
||||
|
||||
result = OnCall.who_is_on_call(schedule.id, ~U[2026-03-01 12:00:00Z])
|
||||
assert result.id == user.id
|
||||
end
|
||||
|
||||
test "returns nil for nonexistent schedule" do
|
||||
assert OnCall.who_is_on_call(Ecto.UUID.generate(), ~U[2026-03-01 12:00:00Z]) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
332
test/towerops_web/live/escalation_policy_live_test.exs
Normal file
332
test/towerops_web/live/escalation_policy_live_test.exs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
defmodule ToweropsWeb.EscalationPolicyLiveTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Towerops.OnCallFixtures
|
||||
|
||||
alias Towerops.OnCall
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{conn: conn, user: user} do
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
|
||||
%{conn: conn, organization: organization}
|
||||
end
|
||||
|
||||
describe "Index (via /schedules?tab=escalation-policies)" do
|
||||
test "shows escalation policies tab content", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
|
||||
|
||||
assert html =~ "Escalation Policies"
|
||||
assert html =~ "New Policy"
|
||||
end
|
||||
|
||||
test "lists escalation policies", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
|
||||
|
||||
assert html =~ "Critical Alerts Policy"
|
||||
assert html =~ "#{policy.repeat_count}"
|
||||
end
|
||||
|
||||
test "shows empty state when no policies", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
|
||||
|
||||
assert html =~ "No escalation policies"
|
||||
end
|
||||
|
||||
test "has link to create new policy", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules?tab=escalation-policies")
|
||||
|
||||
assert has_element?(view, "a", "New Policy")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show" do
|
||||
test "mounts and displays policy details", %{conn: conn, organization: organization} do
|
||||
policy =
|
||||
escalation_policy_fixture(organization.id, %{name: "Ops Policy", repeat_count: 5})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert html =~ "Ops Policy"
|
||||
assert html =~ "5"
|
||||
end
|
||||
|
||||
test "shows description if present", %{conn: conn, organization: organization} do
|
||||
policy =
|
||||
escalation_policy_fixture(organization.id, %{
|
||||
name: "Described Policy",
|
||||
description: "Handles critical network alerts"
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert html =~ "Handles critical network alerts"
|
||||
end
|
||||
|
||||
test "shows empty state when no rules", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert html =~ "No rules configured"
|
||||
end
|
||||
|
||||
test "can toggle add rule form", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
refute html =~ "Escalate after (minutes)"
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button", "Add Rule")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "Escalate after (minutes)"
|
||||
end
|
||||
|
||||
test "can add a rule with timeout_minutes", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
view |> element("button", "Add Rule") |> render_click()
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "Step 1"
|
||||
assert html =~ "15 min"
|
||||
end
|
||||
|
||||
test "can delete a rule", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id, %{timeout_minutes: 20})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert html =~ "20 min"
|
||||
|
||||
view
|
||||
|> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "No rules configured"
|
||||
end
|
||||
|
||||
test "shows rule targets", %{conn: conn, organization: organization, user: user} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
escalation_target_fixture(rule.id, %{
|
||||
target_type: "user",
|
||||
user_id: user.id
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "can add a user target to a rule", %{conn: conn, organization: organization, user: user} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-submit=\"add_target\"]", %{
|
||||
"rule_id" => rule.id,
|
||||
"target_type" => "user",
|
||||
"target_id" => user.id
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "can add a schedule target to a rule", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
schedule = schedule_fixture(organization.id, %{name: "On-Call Rotation"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-submit=\"add_target\"]", %{
|
||||
"rule_id" => rule.id,
|
||||
"target_type" => "schedule",
|
||||
"target_id" => schedule.id
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "On-Call Rotation"
|
||||
end
|
||||
|
||||
test "can delete a target", %{conn: conn, organization: organization, user: user} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
rule = escalation_rule_fixture(policy.id)
|
||||
|
||||
target =
|
||||
escalation_target_fixture(rule.id, %{
|
||||
target_type: "user",
|
||||
user_id: user.id
|
||||
})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert html =~ user.email
|
||||
|
||||
view
|
||||
|> element(~s|button[phx-click="delete_target"][phx-value-id="#{target.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "No targets yet"
|
||||
end
|
||||
|
||||
test "has edit and delete buttons", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert has_element?(view, "a", "Edit")
|
||||
assert has_element?(view, "button", "Delete")
|
||||
end
|
||||
|
||||
test "delete redirects to index with correct tab", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
view
|
||||
|> element("button", "Delete")
|
||||
|> render_click()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path == ~p"/schedules?tab=escalation-policies"
|
||||
assert flash["info"] =~ "deleted"
|
||||
end
|
||||
|
||||
test "requires authentication", %{organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
conn = build_conn()
|
||||
|
||||
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - New" do
|
||||
test "renders new policy form", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
|
||||
|
||||
assert html =~ "New Escalation Policy"
|
||||
assert html =~ "Name"
|
||||
assert html =~ "Description"
|
||||
assert html =~ "Repeat Count"
|
||||
assert html =~ "Save Policy"
|
||||
end
|
||||
|
||||
test "validates required fields", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", escalation_policy: %{name: ""})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "creates policy and redirects to show", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
|
||||
|
||||
view
|
||||
|> form("form", escalation_policy: %{name: "New Alert Policy", repeat_count: 5})
|
||||
|> render_submit()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path =~ ~r|^/schedules/escalation-policies/.+|
|
||||
assert flash["info"] =~ "created"
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/new")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - Edit" do
|
||||
test "renders edit form with existing data", %{conn: conn, organization: organization} do
|
||||
policy =
|
||||
escalation_policy_fixture(organization.id, %{
|
||||
name: "Existing Policy",
|
||||
description: "Some description",
|
||||
repeat_count: 7
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
|
||||
|
||||
assert html =~ "Edit Escalation Policy"
|
||||
assert html =~ "Existing Policy"
|
||||
assert html =~ "Some description"
|
||||
assert html =~ "7"
|
||||
end
|
||||
|
||||
test "validates required fields on change", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", escalation_policy: %{name: ""})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "updates policy and redirects to show", %{conn: conn, organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id, %{name: "Old Name"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
|
||||
|
||||
view
|
||||
|> form("form", escalation_policy: %{name: "Updated Name"})
|
||||
|> render_submit()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path == ~p"/schedules/escalation-policies/#{policy.id}"
|
||||
assert flash["info"] =~ "updated"
|
||||
|
||||
updated = OnCall.get_escalation_policy!(policy.id)
|
||||
assert updated.name == "Updated Name"
|
||||
end
|
||||
|
||||
test "requires authentication", %{organization: organization} do
|
||||
policy = escalation_policy_fixture(organization.id)
|
||||
conn = build_conn()
|
||||
|
||||
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
end
|
||||
499
test/towerops_web/live/schedule_live_test.exs
Normal file
499
test/towerops_web/live/schedule_live_test.exs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
defmodule ToweropsWeb.ScheduleLiveTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Towerops.OnCallFixtures
|
||||
|
||||
alias Towerops.OnCall
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{conn: conn, user: user} do
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
|
||||
%{conn: conn, organization: organization}
|
||||
end
|
||||
|
||||
describe "Index" do
|
||||
test "mounts and lists schedules", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id, %{name: "Primary Rotation"})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ "Schedules"
|
||||
assert html =~ "Primary Rotation"
|
||||
assert html =~ schedule.timezone
|
||||
end
|
||||
|
||||
test "shows empty state when no schedules", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ "No schedules"
|
||||
assert html =~ "Create an on-call schedule"
|
||||
end
|
||||
|
||||
test "shows who is currently on-call for each schedule", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
user: user
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
start_date: ~U[2025-01-01 09:00:00Z],
|
||||
handoff_time: ~T[09:00:00],
|
||||
rotation_type: "weekly"
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, user.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "shows 'No one' when nobody is on-call", %{conn: conn, organization: organization} do
|
||||
schedule_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ "No one"
|
||||
end
|
||||
|
||||
test "has link to create new schedule", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert has_element?(view, "a", "New Schedule")
|
||||
end
|
||||
|
||||
test "has tabs for Schedules and Escalation Policies", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ "Schedules"
|
||||
assert html =~ "Escalation Policies"
|
||||
end
|
||||
|
||||
test "switching to escalation policies tab shows policies", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules")
|
||||
|
||||
html = view |> element("a", "Escalation Policies") |> render_click()
|
||||
|
||||
assert html =~ "Critical Alerts Policy"
|
||||
end
|
||||
|
||||
test "escalation policies tab shows empty state when none exist", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
|
||||
|
||||
assert html =~ "No escalation policies"
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/schedules")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show" do
|
||||
test "mounts and displays schedule details", %{conn: conn, organization: organization} do
|
||||
schedule =
|
||||
schedule_fixture(organization.id, %{
|
||||
name: "Weekend Rotation",
|
||||
timezone: "America/New_York"
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "Weekend Rotation"
|
||||
assert html =~ "America/New_York"
|
||||
end
|
||||
|
||||
test "shows description if present", %{conn: conn, organization: organization} do
|
||||
schedule =
|
||||
schedule_fixture(organization.id, %{
|
||||
name: "Described Schedule",
|
||||
description: "Covers weekday shifts"
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "Covers weekday shifts"
|
||||
end
|
||||
|
||||
test "shows Currently On-Call section with no one on-call", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "Currently On-Call"
|
||||
assert html =~ "No one is currently on-call"
|
||||
end
|
||||
|
||||
test "shows currently on-call user", %{conn: conn, organization: organization, user: user} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
start_date: ~U[2025-01-01 09:00:00Z],
|
||||
handoff_time: ~T[09:00:00],
|
||||
rotation_type: "weekly"
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, user.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "Currently On-Call"
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "shows layers with rotation details", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Primary Layer",
|
||||
rotation_type: "weekly",
|
||||
rotation_interval: 2,
|
||||
handoff_time: ~T[09:00:00]
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "Primary Layer"
|
||||
assert html =~ "Weekly"
|
||||
assert html =~ "09:00"
|
||||
end
|
||||
|
||||
test "shows layer members", %{conn: conn, organization: organization, user: user} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id, %{name: "Layer Alpha"})
|
||||
layer_member_fixture(layer.id, user.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "shows empty state for layers", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "No layers configured"
|
||||
end
|
||||
|
||||
test "shows empty state for overrides", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "No overrides configured"
|
||||
end
|
||||
|
||||
test "can toggle add layer form", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
refute has_element?(view, "form[phx-submit='save_layer']")
|
||||
|
||||
view |> element("button", "Add Layer") |> render_click()
|
||||
|
||||
assert has_element?(view, "form[phx-submit='save_layer']")
|
||||
end
|
||||
|
||||
test "can add a layer", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Add Layer") |> render_click()
|
||||
|
||||
view
|
||||
|> form("form[phx-submit='save_layer']",
|
||||
layer: %{
|
||||
name: "New Layer",
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: "09:00",
|
||||
start_date: "2026-03-01T09:00"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "New Layer"
|
||||
refute has_element?(view, "form[phx-submit='save_layer']")
|
||||
end
|
||||
|
||||
test "can add a member to a layer", %{conn: conn, organization: organization, user: user} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id, %{name: "Staffing Layer"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view
|
||||
|> form("form[phx-submit='add_member']", %{
|
||||
layer_id: layer.id,
|
||||
user_id: user.id
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "can delete a layer", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id, %{name: "Temporary Layer"})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ "Temporary Layer"
|
||||
|
||||
view
|
||||
|> element("button[phx-click='delete_layer'][phx-value-id='#{layer.id}']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
refute html =~ "Temporary Layer"
|
||||
end
|
||||
|
||||
test "can remove a member", %{conn: conn, organization: organization, user: user} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
layer = layer_fixture(schedule.id)
|
||||
member = layer_member_fixture(layer.id, user.id)
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ user.email
|
||||
|
||||
view
|
||||
|> element("button[phx-click='remove_member'][phx-value-id='#{member.id}']")
|
||||
|> render_click()
|
||||
|
||||
# The user email should no longer appear as a member badge
|
||||
refute has_element?(view, "span", user.email)
|
||||
end
|
||||
|
||||
test "can toggle add override form", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
refute has_element?(view, "form[phx-submit='save_override']")
|
||||
|
||||
view |> element("button", "Add Override") |> render_click()
|
||||
|
||||
assert has_element?(view, "form[phx-submit='save_override']")
|
||||
end
|
||||
|
||||
test "can add an override", %{conn: conn, organization: organization, user: user} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Add Override") |> render_click()
|
||||
|
||||
view
|
||||
|> form("form[phx-submit='save_override']",
|
||||
override: %{
|
||||
user_id: user.id,
|
||||
start_time: "2026-04-01T09:00",
|
||||
end_time: "2026-04-02T09:00"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ user.email
|
||||
assert html =~ "2026-04-01"
|
||||
refute has_element?(view, "form[phx-submit='save_override']")
|
||||
end
|
||||
|
||||
test "can delete an override", %{conn: conn, organization: organization, user: user} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
override = override_fixture(schedule.id, user.id)
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert html =~ user.email
|
||||
|
||||
view
|
||||
|> element("button[phx-click='delete_override'][phx-value-id='#{override.id}']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "No overrides configured"
|
||||
end
|
||||
|
||||
test "has edit and delete buttons", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert has_element?(view, "a", "Edit")
|
||||
assert has_element?(view, "button", "Delete")
|
||||
end
|
||||
|
||||
test "delete redirects to index", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Delete") |> render_click()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path == ~p"/schedules"
|
||||
assert flash["info"] =~ "Schedule deleted"
|
||||
end
|
||||
|
||||
test "requires authentication", %{organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - New" do
|
||||
test "renders new schedule form", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/new")
|
||||
|
||||
assert html =~ "New Schedule"
|
||||
assert html =~ "Name"
|
||||
assert html =~ "Timezone"
|
||||
assert html =~ "Save Schedule"
|
||||
end
|
||||
|
||||
test "validates required fields", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/new")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", schedule: %{name: "", timezone: ""})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "creates schedule and redirects to show", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/new")
|
||||
|
||||
view
|
||||
|> form("form",
|
||||
schedule: %{
|
||||
name: "New On-Call Schedule",
|
||||
timezone: "America/Chicago"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path =~ ~r|^/schedules/.+|
|
||||
assert flash["info"] =~ "Schedule created"
|
||||
end
|
||||
|
||||
test "creates schedule with optional description", %{conn: conn, organization: organization} do
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/new")
|
||||
|
||||
view
|
||||
|> form("form",
|
||||
schedule: %{
|
||||
name: "Documented Schedule",
|
||||
timezone: "America/Chicago",
|
||||
description: "Handles weekend coverage"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view)
|
||||
|
||||
schedules = OnCall.list_schedules(organization.id)
|
||||
schedule = Enum.find(schedules, &(&1.name == "Documented Schedule"))
|
||||
assert schedule
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/schedules/new")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - Edit" do
|
||||
test "renders edit form with existing data", %{conn: conn, organization: organization} do
|
||||
schedule =
|
||||
schedule_fixture(organization.id, %{
|
||||
name: "Existing Schedule",
|
||||
timezone: "America/New_York"
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
|
||||
|
||||
assert html =~ "Edit Schedule"
|
||||
assert html =~ "Existing Schedule"
|
||||
assert html =~ "America/New_York"
|
||||
end
|
||||
|
||||
test "validates required fields on change", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", schedule: %{name: ""})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "updates schedule and redirects to show", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id, %{name: "Old Name"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
|
||||
|
||||
view
|
||||
|> form("form", schedule: %{name: "Updated Name"})
|
||||
|> render_submit()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path == ~p"/schedules/#{schedule.id}"
|
||||
assert flash["info"] =~ "Schedule updated"
|
||||
|
||||
updated = OnCall.get_schedule!(schedule.id)
|
||||
assert updated.name == "Updated Name"
|
||||
end
|
||||
|
||||
test "requires authentication", %{organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/schedules/#{schedule.id}/edit")
|
||||
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue