+ <% else %>
+ <% sorted_rules = Enum.sort_by(@policy.rules, & &1.position) %>
+ <% rule_count = length(sorted_rules) %>
+ <%= for {rule, idx} <- Enum.with_index(sorted_rules) do %>
-
-
- {t("Step %{position}", position: rule.position + 1)}
+
+
+ {t("Level %{n}", n: idx + 1)}
-
-
- {t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)}
-
+
+
+
+
+ {t("Notify the following users immediately and escalate after")}
+
+ {rule.timeout_minutes}
+
+ {t("minutes.")}
+
+
<%!-- Targets --%>
-
-
- {t("Targets")}
-
+
<%= if Enum.empty?(rule.targets) do %>
{t("No targets yet")}
@@ -191,7 +172,86 @@
<% end %>
+ <% end %>
+
+ <%!-- Add Level button + form --%>
+ <%= if @show_add_rule do %>
+
+ <% else %>
+
+ <% end %>
+
+ <%!-- REPEAT footer --%>
+
+ <.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" />
+
+ {t("If no one acknowledges, repeat this policy")}
+ {@policy.repeat_count}
+ {t("time(s).")}
+
- <% end %>
+
+
+ <%!-- RIGHT: Services sidebar --%>
+
diff --git a/lib/towerops_web/live/schedule_live/index.html.heex b/lib/towerops_web/live/schedule_live/index.html.heex
index 734cb96e..da9a98d2 100644
--- a/lib/towerops_web/live/schedule_live/index.html.heex
+++ b/lib/towerops_web/live/schedule_live/index.html.heex
@@ -79,10 +79,7 @@
{t("Name")}
- {t("Rules")}
- |
-
- {t("Repeat Count")}
+ {t("Levels")}
|
@@ -96,10 +93,7 @@
{policy.name}
- —
- |
-
- {policy.repeat_count}
+ {length(policy.rules)}
|
<% end %>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt
index 7e8dd098..1526aa8b 100644
--- a/priv/static/changelog.txt
+++ b/priv/static/changelog.txt
@@ -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
diff --git a/test/towerops/on_call_test.exs b/test/towerops/on_call_test.exs
index 3c50deb5..4dd98950 100644
--- a/test/towerops/on_call_test.exs
+++ b/test/towerops/on_call_test.exs
@@ -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)
diff --git a/test/towerops_web/live/escalation_policy_live_test.exs b/test/towerops_web/live/escalation_policy_live_test.exs
index e8c967a7..3c8eb78e 100644
--- a/test/towerops_web/live/escalation_policy_live_test.exs
+++ b/test/towerops_web/live/escalation_policy_live_test.exs
@@ -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/
]*>\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/ |