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 2026-04-04
perf: database performance and maintenance migrations perf: database performance and maintenance migrations
- Added GiST index on geoip_blocks using int8range for IP range containment queries - 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 import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.OnCall.EscalationPolicy alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationRule alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.EscalationTarget alias Towerops.OnCall.EscalationTarget
@ -13,6 +14,7 @@ defmodule Towerops.OnCall do
alias Towerops.OnCall.Override alias Towerops.OnCall.Override
alias Towerops.OnCall.Resolver alias Towerops.OnCall.Resolver
alias Towerops.OnCall.Schedule alias Towerops.OnCall.Schedule
alias Towerops.Organizations.Organization
alias Towerops.Repo alias Towerops.Repo
# --- Schedules --- # --- Schedules ---
@ -113,6 +115,7 @@ defmodule Towerops.OnCall do
|> where([p], p.organization_id == ^organization_id) |> where([p], p.organization_id == ^organization_id)
|> order_by([p], asc: p.name) |> order_by([p], asc: p.name)
|> Repo.all() |> Repo.all()
|> Repo.preload(:rules)
end end
def get_escalation_policy!(id) do def get_escalation_policy!(id) do
@ -173,6 +176,55 @@ defmodule Towerops.OnCall do
Repo.delete(rule) Repo.delete(rule)
end 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 --- # --- Escalation Targets ---
def create_escalation_target(attrs) do def create_escalation_target(attrs) do
@ -185,6 +237,28 @@ defmodule Towerops.OnCall do
Repo.delete(target) Repo.delete(target)
end 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 --- # --- On-Call Resolution ---
def who_is_on_call(schedule_id, datetime \\ DateTime.utc_now()) do 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) table = Keyword.get(opts, :table, @default_table)
clean_period = Keyword.get(opts, :clean_period, @default_clean_period) clean_period = Keyword.get(opts, :clean_period, @default_clean_period)
:ets.new(table, [ _ =
:named_table, :ets.new(table, [
:set, :named_table,
:public, :set,
{:read_concurrency, true}, :public,
{:write_concurrency, true}, {:read_concurrency, true},
{:decentralized_counters, true} {:write_concurrency, true},
]) {:decentralized_counters, true}
])
schedule_clean(clean_period) _ = schedule_clean(clean_period)
{:ok, %{table: table, clean_period: clean_period}} {:ok, %{table: table, clean_period: clean_period}}
end end

View file

@ -23,13 +23,24 @@
</div> </div>
<div> <div>
<.input <.input
field={@form[:repeat_count]} field={@form[:handoff_notifications_mode]}
type="number" type="select"
label={t("Repeat Count")} label={t("Send On-Call Handoff Notifications")}
min="1" options={[
max="10" {t("When in use by a service"), "when_in_use_by_service"},
{t("Always"), "always"},
{t("Never"), "never"}
]}
/> />
</div> </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"> <div class="flex items-center gap-3">
<.button type="submit" phx-disable-with={t("Saving...")}> <.button type="submit" phx-disable-with={t("Saving...")}>
{t("Save Policy")} {t("Save Policy")}

View file

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

View file

@ -12,6 +12,7 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
members = Organizations.list_organization_members(org_id) members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user) users = Enum.map(members, & &1.user)
schedules = OnCall.list_schedules(org_id) schedules = OnCall.list_schedules(org_id)
services = OnCall.list_devices_using_policy(policy.id, org_id)
{:ok, {:ok,
socket socket
@ -20,9 +21,16 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
|> assign(:policy, policy) |> assign(:policy, policy)
|> assign(:org_users, users) |> assign(:org_users, users)
|> assign(:org_schedules, schedules) |> assign(:org_schedules, schedules)
|> assign(:services, services)
|> assign(:show_add_rule, false)} |> assign(:show_add_rule, false)}
end 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 @impl true
def handle_event("delete", _params, socket) do def handle_event("delete", _params, socket) do
case OnCall.delete_escalation_policy(socket.assigns.policy) do case OnCall.delete_escalation_policy(socket.assigns.policy) do
@ -92,6 +100,17 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
end end
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 def handle_event("delete_target", %{"id" => target_id}, socket) do
target = target =
socket.assigns.policy.rules socket.assigns.policy.rules
@ -109,7 +128,12 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
end end
defp reload_policy(socket) do defp reload_policy(socket) do
org_id = socket.assigns.current_scope.organization.id
policy = OnCall.get_escalation_policy!(socket.assigns.policy.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
end end

View file

@ -3,7 +3,7 @@
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" 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"> <div class="flex items-center gap-3">
<.link <.link
navigate={~p"/schedules?tab=escalation-policies"} navigate={~p"/schedules?tab=escalation-policies"}
@ -11,12 +11,7 @@
> >
<.icon name="hero-arrow-left" class="h-5 w-5" /> <.icon name="hero-arrow-left" class="h-5 w-5" />
</.link> </.link>
<div> <h1 class="text-xl font-bold text-gray-900 dark:text-white">{@policy.name}</h1>
<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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<.link <.link
@ -37,92 +32,78 @@
</div> </div>
</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 %> <%= if @policy.description do %>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@policy.description}</p> <p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@policy.description}</p>
<% end %> <% end %>
<div> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="flex items-center justify-between mb-4"> <%!-- LEFT: Levels timeline --%>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white"> <div class="lg:col-span-2 space-y-3">
{t("Escalation Rules")} <%= if Enum.empty?(@policy.rules) do %>
</h2> <div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<button <p class="text-sm text-gray-500 dark:text-gray-400">
phx-click="toggle_add_rule" {t("No levels configured. Add a level to define escalation steps.")}
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500" </p>
> </div>
<.icon name="hero-plus" class="h-4 w-4" /> <% else %>
{t("Add Rule")} <% sorted_rules = Enum.sort_by(@policy.rules, & &1.position) %>
</button> <% rule_count = length(sorted_rules) %>
</div> <%= for {rule, idx} <- Enum.with_index(sorted_rules) do %>
<%= 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="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 class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="text-base font-semibold text-gray-900 dark:text-white">
{t("Step %{position}", position: rule.position + 1)} {t("Level %{n}", n: idx + 1)}
</h3> </h3>
<div class="flex items-center gap-3"> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400"> <button
{t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)} phx-click="move_rule"
</span> 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 <button
phx-click="delete_rule" phx-click="delete_rule"
phx-value-id={rule.id} phx-value-id={rule.id}
data-confirm={t("Are you sure you want to delete this rule?")} data-confirm={t("Delete this level?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors" 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" /> <.icon name="hero-trash" class="h-4 w-4" />
</button> </button>
</div> </div>
</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 --%> <%!-- Targets --%>
<div class="mb-2"> <div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Targets")}
</p>
<%= if Enum.empty?(rule.targets) do %> <%= if Enum.empty?(rule.targets) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2"> <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No targets yet")} {t("No targets yet")}
@ -191,7 +172,86 @@
</div> </div>
</div> </div>
<% end %> <% 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> </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> </div>
</Layouts.authenticated> </Layouts.authenticated>

View file

@ -79,10 +79,7 @@
{t("Name")} {t("Name")}
</th> </th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Rules")} {t("Levels")}
</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> </th>
</tr> </tr>
</thead> </thead>
@ -96,10 +93,7 @@
{policy.name} {policy.name}
</td> </td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400"> <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{length(policy.rules)}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{policy.repeat_count}
</td> </td>
</tr> </tr>
<% end %> <% 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 2026-04-04 — Performance Improvements
* Significantly faster IP geolocation lookups * Significantly faster IP geolocation lookups
* Reduced database maintenance overhead for background job processing * Reduced database maintenance overhead for background job processing

View file

@ -2,6 +2,7 @@ defmodule Towerops.OnCallTest do
use Towerops.DataCase, async: true use Towerops.DataCase, async: true
import Towerops.AccountsFixtures import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OnCallFixtures import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures import Towerops.OrganizationsFixtures
@ -191,6 +192,167 @@ defmodule Towerops.OnCallTest do
end end
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 describe "who_is_on_call/2" do
test "resolves who is on call from schedule id", %{user: user, organization: org} do test "resolves who is on call from schedule id", %{user: user, organization: org} do
schedule = schedule_fixture(org.id) schedule = schedule_fixture(org.id)

View file

@ -2,7 +2,9 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
use ToweropsWeb.ConnCase, async: true use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Towerops.DevicesFixtures
import Towerops.OnCallFixtures import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.OnCall alias Towerops.OnCall
@ -23,12 +25,26 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
end end
test "lists escalation policies", %{conn: conn, organization: organization} do 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") {:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "Critical Alerts Policy" 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 end
test "shows empty state when no policies", %{conn: conn} do test "shows empty state when no policies", %{conn: conn} do
@ -67,15 +83,15 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
assert html =~ "Handles critical network alerts" assert html =~ "Handles critical network alerts"
end 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) policy = escalation_policy_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "No rules configured" assert html =~ "No levels configured"
end 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) policy = escalation_policy_fixture(organization.id)
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") {:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
@ -84,42 +100,150 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
html = html =
view view
|> element("button", "Add Rule") |> element("button", "Add Level")
|> render_click() |> render_click()
assert html =~ "Escalate after (minutes)" assert html =~ "Escalate after (minutes)"
end 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) policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.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 = html =
view view
|> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"}) |> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"})
|> render_submit() |> render_submit()
assert html =~ "Step 1" assert html =~ "Level 1"
assert html =~ "15 min" assert html =~ "15"
assert html =~ "minutes"
end 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) policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id, %{timeout_minutes: 20}) rule = escalation_rule_fixture(policy.id, %{timeout_minutes: 20})
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") {:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "20 min" assert html =~ "20"
view view
|> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|) |> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|)
|> render_click() |> render_click()
html = render(view) 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 end
test "shows rule targets", %{conn: conn, organization: organization, user: user} do 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 =~ "New Escalation Policy"
assert html =~ "Name" assert html =~ "Name"
assert html =~ "Description" assert html =~ "Description"
assert html =~ "Repeat Count"
assert html =~ "Save Policy" assert html =~ "Save Policy"
end 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 test "validates required fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new") {:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
@ -319,6 +464,29 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
assert updated.name == "Updated Name" assert updated.name == "Updated Name"
end 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 test "requires authentication", %{organization: organization} do
policy = escalation_policy_fixture(organization.id) policy = escalation_policy_fixture(organization.id)
conn = build_conn() conn = build_conn()