feat(on_call): PagerDuty-style escalation policy redesign + dialyzer fix

Chunk 1 of the on-call/escalation redesign plan
(docs/plans/2026-04-28-on-call-pagerduty-style-redesign.md):

Schema
- on_call.ex: list_devices_using_policy/2 (direct + org-default inheritance)
- on_call.ex: move_escalation_rule/2 for up/down level reordering
- list_escalation_policies/1 now preloads :rules

Show page
- Levels rendered as numbered cards with up/down/delete controls
- "Notify the following users immediately and escalate after N minutes."
- REPEAT footer surfaces repeat_count inline (no longer a standalone column)
- Right-side Services sidebar lists devices using the policy
- Header line shows the handoff notifications mode (when in use by a service /
  always / never)

Edit form
- Removed standalone Repeat Count input
- Added handoff notifications mode select
- Added inline "If no one acknowledges, repeat this policy N time(s)" block

Index views (policies tab + standalone)
- Replaced Repeat Count column with a Levels (rule count) column

Other
- rate_limit.ex: bind unmatched :ets.new/2 + schedule_clean/1 returns to fix
  dialyzer :unmatched_returns warning under the project's strict flags.

All 10,191 tests pass; mix format clean; mix compile --warnings-as-errors clean;
mix dialyzer passes.
This commit is contained in:
Graham McIntire 2026-04-28 12:36:17 -05:00
parent 2e6729777b
commit a91c00f3cf
11 changed files with 639 additions and 117 deletions

View file

@ -1,3 +1,24 @@
2026-04-28
feat(on_call): PagerDuty-style escalation policy redesign (chunk 1)
- Added handoff_notifications_mode field to escalation_policies
(when_in_use_by_service / always / never; default when_in_use_by_service)
- Added OnCall.list_devices_using_policy/2 (devices via direct ref or org default)
- Added OnCall.move_escalation_rule/2 for up/down level reordering
- Show page rebuilt: numbered Level cards, up/down/delete per-level, REPEAT footer
surfacing repeat_count, Services sidebar listing devices using the policy
- Edit form: removed standalone Repeat Count input; added handoff mode select
and inline "If no one acknowledges, repeat this policy N time(s)" block
- Index views (policies tab + standalone): replaced Repeat Count column with
a Levels count column; rules now preloaded by list_escalation_policies/1
- Plan: docs/plans/2026-04-28-on-call-pagerduty-style-redesign.md
Files: lib/towerops/on_call/escalation_policy.ex, lib/towerops/on_call.ex,
priv/repo/migrations/20260428170617_add_handoff_notifications_mode_to_escalation_policies.exs,
lib/towerops_web/live/escalation_policy_live/{show,form,index}.{ex,html.heex},
lib/towerops_web/live/schedule_live/index.html.heex,
test/towerops/on_call_test.exs,
test/towerops/on_call/escalation_policy_test.exs,
test/towerops_web/live/escalation_policy_live_test.exs
2026-04-04
perf: database performance and maintenance migrations
- Added GiST index on geoip_blocks using int8range for IP range containment queries

View file

@ -5,6 +5,7 @@ defmodule Towerops.OnCall do
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget
@ -13,6 +14,7 @@ defmodule Towerops.OnCall do
alias Towerops.OnCall.Override
alias Towerops.OnCall.Resolver
alias Towerops.OnCall.Schedule
alias Towerops.Organizations.Organization
alias Towerops.Repo
# --- Schedules ---
@ -113,6 +115,7 @@ defmodule Towerops.OnCall do
|> where([p], p.organization_id == ^organization_id)
|> order_by([p], asc: p.name)
|> Repo.all()
|> Repo.preload(:rules)
end
def get_escalation_policy!(id) do
@ -173,6 +176,55 @@ defmodule Towerops.OnCall do
Repo.delete(rule)
end
@doc """
Swaps a rule with its neighbour in the given direction (`:up` or `:down`).
Returns `{:ok, rule}` (unchanged when at the boundary), or `{:error, reason}`.
"""
def move_escalation_rule(%EscalationRule{} = rule, direction) when direction in [:up, :down] do
policy_rules =
EscalationRule
|> where([r], r.escalation_policy_id == ^rule.escalation_policy_id)
|> order_by([r], asc: r.position)
|> Repo.all()
case find_neighbour(policy_rules, rule, direction) do
nil ->
{:ok, rule}
neighbour ->
case swap_rule_positions(rule, neighbour) do
:ok -> {:ok, Repo.get!(EscalationRule, rule.id)}
{:error, _} = err -> err
end
end
end
defp find_neighbour(rules, rule, :up), do: Enum.find(rules, &(&1.position == rule.position - 1))
defp find_neighbour(rules, rule, :down), do: Enum.find(rules, &(&1.position == rule.position + 1))
defp swap_rule_positions(a, b) do
a_pos = a.position
b_pos = b.position
fn ->
{:ok, _} =
a
|> EscalationRule.changeset(%{position: b_pos})
|> Repo.update()
{:ok, _} =
b
|> EscalationRule.changeset(%{position: a_pos})
|> Repo.update()
end
|> Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
# --- Escalation Targets ---
def create_escalation_target(attrs) do
@ -185,6 +237,28 @@ defmodule Towerops.OnCall do
Repo.delete(target)
end
@doc """
Returns devices in `organization_id` whose effective escalation policy is
`policy_id` either set directly on the device, or inherited from the
organization's default policy.
"""
def list_devices_using_policy(policy_id, organization_id) do
org_default_query =
from o in Organization,
where: o.id == ^organization_id and o.default_escalation_policy_id == ^policy_id,
select: o.id
Device
|> where([d], d.organization_id == ^organization_id)
|> where(
[d],
d.escalation_policy_id == ^policy_id or
(is_nil(d.escalation_policy_id) and d.organization_id in subquery(org_default_query))
)
|> order_by([d], asc: d.name)
|> Repo.all()
end
# --- On-Call Resolution ---
def who_is_on_call(schedule_id, datetime \\ DateTime.utc_now()) do

View file

@ -153,16 +153,17 @@ defmodule Towerops.RateLimit do
table = Keyword.get(opts, :table, @default_table)
clean_period = Keyword.get(opts, :clean_period, @default_clean_period)
:ets.new(table, [
:named_table,
:set,
:public,
{:read_concurrency, true},
{:write_concurrency, true},
{:decentralized_counters, true}
])
_ =
:ets.new(table, [
:named_table,
:set,
:public,
{:read_concurrency, true},
{:write_concurrency, true},
{:decentralized_counters, true}
])
schedule_clean(clean_period)
_ = schedule_clean(clean_period)
{:ok, %{table: table, clean_period: clean_period}}
end

View file

@ -23,13 +23,24 @@
</div>
<div>
<.input
field={@form[:repeat_count]}
type="number"
label={t("Repeat Count")}
min="1"
max="10"
field={@form[:handoff_notifications_mode]}
type="select"
label={t("Send On-Call Handoff Notifications")}
options={[
{t("When in use by a service"), "when_in_use_by_service"},
{t("Always"), "always"},
{t("Never"), "never"}
]}
/>
</div>
<div class="rounded-lg border border-gray-200 dark:border-white/10 p-4 flex items-center gap-3">
<.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" />
<span class="text-sm text-gray-700 dark:text-gray-300">
{t("If no one acknowledges, repeat this policy")}
</span>
<.input field={@form[:repeat_count]} type="number" min="1" max="10" class="w-20" />
<span class="text-sm text-gray-700 dark:text-gray-300">{t("time(s).")}</span>
</div>
<div class="flex items-center gap-3">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Save Policy")}

View file

@ -46,7 +46,7 @@
{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")}
{t("Levels")}
</th>
</tr>
</thead>
@ -60,7 +60,7 @@
{policy.name}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{policy.repeat_count}
{length(policy.rules)}
</td>
</tr>
<% end %>

View file

@ -12,6 +12,7 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user)
schedules = OnCall.list_schedules(org_id)
services = OnCall.list_devices_using_policy(policy.id, org_id)
{:ok,
socket
@ -20,9 +21,16 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
|> assign(:policy, policy)
|> assign(:org_users, users)
|> assign(:org_schedules, schedules)
|> assign(:services, services)
|> assign(:show_add_rule, false)}
end
@doc false
def handoff_mode_label("when_in_use_by_service"), do: "when in use by a service"
def handoff_mode_label("always"), do: "always"
def handoff_mode_label("never"), do: "never"
def handoff_mode_label(_), do: "when in use by a service"
@impl true
def handle_event("delete", _params, socket) do
case OnCall.delete_escalation_policy(socket.assigns.policy) do
@ -92,6 +100,17 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
end
end
def handle_event("move_rule", %{"id" => id, "direction" => direction}, socket) when direction in ["up", "down"] do
rule = Enum.find(socket.assigns.policy.rules, &(&1.id == id))
if rule do
{:ok, _} = OnCall.move_escalation_rule(rule, String.to_existing_atom(direction))
{:noreply, reload_policy(socket)}
else
{:noreply, socket}
end
end
def handle_event("delete_target", %{"id" => target_id}, socket) do
target =
socket.assigns.policy.rules
@ -109,7 +128,12 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
end
defp reload_policy(socket) do
org_id = socket.assigns.current_scope.organization.id
policy = OnCall.get_escalation_policy!(socket.assigns.policy.id)
assign(socket, :policy, policy)
services = OnCall.list_devices_using_policy(policy.id, org_id)
socket
|> assign(:policy, policy)
|> assign(:services, services)
end
end

View file

@ -3,7 +3,7 @@
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<.link
navigate={~p"/schedules?tab=escalation-policies"}
@ -11,12 +11,7 @@
>
<.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>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@policy.name}</h1>
</div>
<div class="flex items-center gap-2">
<.link
@ -37,92 +32,78 @@
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t("Send On-Call Handoff Notifications:")}
<span class="font-medium text-gray-900 dark:text-white">
{handoff_mode_label(@policy.handoff_notifications_mode)}
</span>
</p>
<%= 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="grid grid-cols-1 lg:grid-cols-3 gap-6">
<%!-- LEFT: Levels timeline --%>
<div class="lg:col-span-2 space-y-3">
<%= 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 levels configured. Add a level to define escalation steps.")}
</p>
</div>
<% else %>
<% sorted_rules = Enum.sort_by(@policy.rules, & &1.position) %>
<% rule_count = length(sorted_rules) %>
<%= for {rule, idx} <- Enum.with_index(sorted_rules) 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)}
<div class="flex items-center justify-between mb-2">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{t("Level %{n}", n: idx + 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>
<div class="flex items-center gap-1">
<button
phx-click="move_rule"
phx-value-id={rule.id}
phx-value-direction="up"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
disabled={idx == 0}
title={t("Move up")}
>
<.icon name="hero-arrow-up" class="h-4 w-4" />
</button>
<button
phx-click="move_rule"
phx-value-id={rule.id}
phx-value-direction="down"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
disabled={idx == rule_count - 1}
title={t("Move down")}
>
<.icon name="hero-arrow-down" class="h-4 w-4" />
</button>
<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"
data-confirm={t("Delete this level?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors ml-1"
title={t("Delete")}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
{t("Notify the following users immediately and escalate after")}
<span class="font-semibold text-gray-900 dark:text-white">
{rule.timeout_minutes}
</span>
{t("minutes.")}
</p>
<%!-- Targets --%>
<div class="mb-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Targets")}
</p>
<div>
<%= if Enum.empty?(rule.targets) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No targets yet")}
@ -191,7 +172,86 @@
</div>
</div>
<% end %>
<% end %>
<%!-- Add Level button + form --%>
<%= if @show_add_rule do %>
<div class="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 Level")}
</.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>
<% else %>
<button
phx-click="toggle_add_rule"
class="w-full rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-3 text-sm text-gray-500 dark:text-gray-400 hover:border-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4 inline mr-1" /> {t("Add Level")}
</button>
<% end %>
<%!-- REPEAT footer --%>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-800/40 p-4 flex items-center gap-3">
<.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" />
<p class="text-sm text-gray-700 dark:text-gray-300">
{t("If no one acknowledges, repeat this policy")}
<span class="font-semibold">{@policy.repeat_count}</span>
{t("time(s).")}
</p>
</div>
<% end %>
</div>
<%!-- RIGHT: Services sidebar --%>
<aside class="lg:col-span-1">
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Services (%{count})", count: length(@services))}
</h3>
<%= if @services == [] do %>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No devices use this policy yet.")}
</p>
<% else %>
<ul class="space-y-2">
<%= for device <- @services do %>
<li>
<.link
navigate={~p"/devices/#{device.id}"}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{device.name}
</.link>
</li>
<% end %>
</ul>
<% end %>
</div>
</aside>
</div>
</Layouts.authenticated>

View file

@ -79,10 +79,7 @@
{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")}
{t("Levels")}
</th>
</tr>
</thead>
@ -96,10 +93,7 @@
{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}
{length(policy.rules)}
</td>
</tr>
<% end %>

View file

@ -1,3 +1,10 @@
2026-04-28 — Escalation Policy Improvements
* Redesigned escalation policy detail page with numbered Level cards and inline reorder controls
* Added a "Services" sidebar showing the devices that use each policy
* Surfaced the on-call handoff notifications setting on policies (when in use by a service / always / never)
* Replaced the standalone Repeat Count field with an inline "If no one acknowledges, repeat this policy N time(s)" control on the policy timeline
* Policy list views now show the number of escalation levels instead of a separate repeat count column
2026-04-04 — Performance Improvements
* Significantly faster IP geolocation lookups
* Reduced database maintenance overhead for background job processing

View file

@ -2,6 +2,7 @@ defmodule Towerops.OnCallTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
@ -191,6 +192,167 @@ defmodule Towerops.OnCallTest do
end
end
describe "list_devices_using_policy/2" do
test "returns devices that point at the policy directly", %{user: user} do
org = organization_fixture(user.id)
policy = escalation_policy_fixture(org.id)
site = site_fixture(org.id)
direct =
device_fixture(org.id, site.id, %{
escalation_policy_id: policy.id,
name: "router-direct"
})
_other =
device_fixture(org.id, site.id, %{
escalation_policy_id: nil,
name: "router-no-policy"
})
assert [device] = OnCall.list_devices_using_policy(policy.id, org.id)
assert device.id == direct.id
end
test "returns devices that inherit via the org default policy", %{user: user} do
org = organization_fixture(user.id)
policy = escalation_policy_fixture(org.id)
{:ok, _} =
Towerops.Organizations.update_organization(org, %{default_escalation_policy_id: policy.id})
site = site_fixture(org.id)
inherited =
device_fixture(org.id, site.id, %{
escalation_policy_id: nil,
name: "router-inherited"
})
ids =
policy.id
|> OnCall.list_devices_using_policy(org.id)
|> Enum.map(& &1.id)
assert inherited.id in ids
end
test "scopes to the organization", %{user: user} do
org_a = organization_fixture(user.id)
org_b = organization_fixture(user.id)
policy = escalation_policy_fixture(org_a.id)
site_a = site_fixture(org_a.id)
site_b = site_fixture(org_b.id)
_in_org =
device_fixture(org_a.id, site_a.id, %{
escalation_policy_id: policy.id,
name: "router-a"
})
_other_org =
device_fixture(org_b.id, site_b.id, %{
escalation_policy_id: policy.id,
name: "router-b"
})
results = OnCall.list_devices_using_policy(policy.id, org_a.id)
assert length(results) == 1
assert hd(results).name == "router-a"
end
end
describe "move_escalation_rule/2" do
test "swaps positions with the neighbour above when direction is :up", %{organization: org} do
policy = escalation_policy_fixture(org.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
{:ok, r1} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 1,
timeout_minutes: 30
})
{:ok, _} = OnCall.move_escalation_rule(r1, :up)
by_id =
policy.id
|> OnCall.get_escalation_policy!()
|> Map.fetch!(:rules)
|> Map.new(&{&1.id, &1.position})
assert by_id[r0.id] == 1
assert by_id[r1.id] == 0
end
test "is a no-op when moving the top rule :up", %{organization: org} do
policy = escalation_policy_fixture(org.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
assert {:ok, returned} = OnCall.move_escalation_rule(r0, :up)
assert returned.id == r0.id
assert returned.position == 0
end
test "is a no-op when moving the bottom rule :down", %{organization: org} do
policy = escalation_policy_fixture(org.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
assert {:ok, returned} = OnCall.move_escalation_rule(r0, :down)
assert returned.id == r0.id
assert returned.position == 0
end
test "swaps with the neighbour below when direction is :down", %{organization: org} do
policy = escalation_policy_fixture(org.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
{:ok, r1} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 1,
timeout_minutes: 30
})
{:ok, _} = OnCall.move_escalation_rule(r0, :down)
by_id =
policy.id
|> OnCall.get_escalation_policy!()
|> Map.fetch!(:rules)
|> Map.new(&{&1.id, &1.position})
assert by_id[r0.id] == 1
assert by_id[r1.id] == 0
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)

View file

@ -2,7 +2,9 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.DevicesFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.OnCall
@ -23,12 +25,26 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
end
test "lists escalation policies", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
_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 the level count instead of a Repeat Count column", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id, %{name: "p", repeat_count: 5})
_ = escalation_rule_fixture(policy.id, %{position: 0})
_ = escalation_rule_fixture(policy.id, %{position: 1})
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
refute html =~ "Repeat Count"
assert html =~ "Levels"
assert html =~ ~r/<td[^>]*>\s*2\s*</
end
test "shows empty state when no policies", %{conn: conn} do
@ -67,15 +83,15 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
assert html =~ "Handles critical network alerts"
end
test "shows empty state when no rules", %{conn: conn, organization: organization} do
test "shows empty state when no levels", %{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"
assert html =~ "No levels configured"
end
test "can toggle add rule form", %{conn: conn, organization: organization} do
test "can toggle add level form", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
@ -84,42 +100,150 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
html =
view
|> element("button", "Add Rule")
|> element("button", "Add Level")
|> render_click()
assert html =~ "Escalate after (minutes)"
end
test "can add a rule with timeout_minutes", %{conn: conn, organization: organization} do
test "can add a level 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()
view |> element("button", "Add Level") |> render_click()
html =
view
|> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"})
|> render_submit()
assert html =~ "Step 1"
assert html =~ "15 min"
assert html =~ "Level 1"
assert html =~ "15"
assert html =~ "minutes"
end
test "can delete a rule", %{conn: conn, organization: organization} do
test "can delete a level", %{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"
assert html =~ "20"
view
|> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|)
|> render_click()
html = render(view)
assert html =~ "No rules configured"
assert html =~ "No levels configured"
end
test "renders levels as numbered cards with escalate-after copy", %{
conn: conn,
organization: organization,
user: user
} do
policy = escalation_policy_fixture(organization.id, %{name: "Net"})
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 15
})
{:ok, _} =
OnCall.create_escalation_target(%{
escalation_rule_id: r0.id,
target_type: "user",
user_id: user.id
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Level 1"
assert html =~ "escalate after"
assert html =~ "15"
assert html =~ "minutes"
end
test "surfaces repeat_count in the REPEAT footer", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id, %{repeat_count: 4})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "If no one acknowledges, repeat this policy"
assert html =~ "4"
end
test "shows the handoff notifications mode label", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{handoff_notifications_mode: "always"})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Send On-Call Handoff Notifications"
assert html =~ "always"
end
test "lists devices using the policy in the Services sidebar", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id)
site = site_fixture(organization.id)
device =
device_fixture(organization.id, site.id, %{
escalation_policy_id: policy.id,
name: "core-router-7"
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Services"
assert html =~ device.name
end
test "Services sidebar shows empty state when nothing uses the policy", %{
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 devices use this policy"
end
test "can move a level up via the move_rule event", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
{:ok, r1} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 1,
timeout_minutes: 60
})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
view
|> element(~s|button[phx-click="move_rule"][phx-value-id="#{r1.id}"][phx-value-direction="up"]|)
|> render_click()
reloaded = OnCall.get_escalation_policy!(policy.id)
by_id = Map.new(reloaded.rules, &{&1.id, &1.position})
assert by_id[r0.id] == 1
assert by_id[r1.id] == 0
end
test "shows rule targets", %{conn: conn, organization: organization, user: user} do
@ -236,10 +360,31 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
assert html =~ "New Escalation Policy"
assert html =~ "Name"
assert html =~ "Description"
assert html =~ "Repeat Count"
assert html =~ "Save Policy"
end
test "no longer renders a standalone Repeat Count label", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
refute html =~ ~r/<label[^>]*>\s*Repeat Count\s*</
end
test "renders the Send On-Call Handoff Notifications select", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
assert html =~ "Send On-Call Handoff Notifications"
assert html =~ "When in use by a service"
assert html =~ "Always"
assert html =~ "Never"
end
test "renders the inline repeat block", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
assert html =~ "If no one acknowledges, repeat this policy"
assert html =~ ~r/name="escalation_policy\[repeat_count\]"/
end
test "validates required fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
@ -319,6 +464,29 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
assert updated.name == "Updated Name"
end
test "edit form persists handoff_notifications_mode and inline repeat_count", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id, %{repeat_count: 2})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
view
|> form("form",
escalation_policy: %{
name: policy.name,
repeat_count: 5,
handoff_notifications_mode: "always"
}
)
|> render_submit()
updated = OnCall.get_escalation_policy!(policy.id)
assert updated.repeat_count == 5
assert updated.handoff_notifications_mode == "always"
end
test "requires authentication", %{organization: organization} do
policy = escalation_policy_fixture(organization.id)
conn = build_conn()