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:
parent
2e6729777b
commit
a91c00f3cf
11 changed files with 639 additions and 117 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue