towerops/lib/towerops_web/live/escalation_policy_live/show.html.heex
Graham McIntire 0cd2ed3567
feat: add on-call schedules and escalation policies
Built-in PagerDuty-equivalent system for on-call scheduling and alert
escalation. Users can now manage schedules, rotation layers, overrides,
and escalation policies directly in the app alongside PagerDuty.

- On-call schedules with rotation layers (daily/weekly/custom), member
  management, and temporary overrides
- Escalation policies with ordered rules, timeout-based escalation,
  and user/schedule targets
- Automatic escalation via Oban worker with configurable repeat count
- Email notifications via Swoosh for on-call alerts
- Resolver computes who's on-call from layer stacking and overrides
- AlertNotificationWorker integration: starts escalation alongside
  PagerDuty, acknowledges/resolves incidents on alert state changes
- Device and organization schemas support escalation_policy_id
- Escalation policy picker on device form
- Schedules nav item with tabbed index (schedules + escalation policies)
- Full CRUD UI for schedules, layers, members, overrides, rules, targets
- 62 LiveView tests, 56 context/schema/resolver/escalation tests
- 26 E2E Playwright tests for smoke and critical path coverage
2026-03-11 12:32:54 -05:00

197 lines
8.5 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<.link
navigate={~p"/schedules?tab=escalation-policies"}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@policy.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("Repeats %{count} times", count: @policy.repeat_count)}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<.link
navigate={~p"/schedules/escalation-policies/#{@policy.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-pencil" class="h-4 w-4" />
{t("Edit")}
</.link>
<button
phx-click="delete"
data-confirm={t("Are you sure you want to delete this escalation policy?")}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
{t("Delete")}
</button>
</div>
</div>
<%= if @policy.description do %>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@policy.description}</p>
<% end %>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("Escalation Rules")}
</h2>
<button
phx-click="toggle_add_rule"
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("Add Rule")}
</button>
</div>
<%= if @show_add_rule do %>
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
<form phx-submit="save_rule">
<div class="flex items-end gap-4 mb-4">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Escalate after (minutes)")}
</label>
<input
type="number"
name="timeout_minutes"
value="30"
min="1"
max="1440"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm"
/>
</div>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Rule")}
</.button>
<button
type="button"
phx-click="toggle_add_rule"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
</div>
</form>
</div>
<% end %>
<%= if Enum.empty?(@policy.rules) do %>
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No rules configured. Add a rule to define escalation steps.")}
</p>
</div>
<% else %>
<div class="space-y-3">
<%= for rule <- Enum.sort_by(@policy.rules, & &1.position) do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Step %{position}", position: rule.position + 1)}
</h3>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)}
</span>
<button
phx-click="delete_rule"
phx-value-id={rule.id}
data-confirm={t("Are you sure you want to delete this rule?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</div>
<%!-- Targets --%>
<div class="mb-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Targets")}
</p>
<%= if Enum.empty?(rule.targets) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No targets yet")}
</p>
<% else %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for target <- rule.targets do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
<%= case target.target_type do %>
<% "user" -> %>
<.icon name="hero-user" class="h-3 w-3 mr-0.5" />
<% user = Enum.find(@org_users, &(&1.id == target.user_id)) %>
{if user, do: user.email, else: t("Unknown user")}
<% "schedule" -> %>
<.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" />
<% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %>
{if sched, do: sched.name, else: t("Unknown schedule")}
<% end %>
<button
phx-click="delete_target"
phx-value-id={target.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<%!-- Add target form --%>
<form phx-submit="add_target" class="flex items-center gap-2">
<input type="hidden" name="rule_id" value={rule.id} />
<select
name="target_type"
id={"target-type-#{rule.id}"}
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="user">{t("User")}</option>
<option value="schedule">{t("Schedule")}</option>
</select>
<select
name="target_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="">{t("Select...")}</option>
<optgroup label={t("Users")}>
<%= for user <- @org_users do %>
<option value={user.id}>{user.email}</option>
<% end %>
</optgroup>
<optgroup label={t("Schedules")}>
<%= for sched <- @org_schedules do %>
<option value={sched.id}>{sched.name}</option>
<% end %>
</optgroup>
</select>
<button
type="submit"
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
{t("Add")}
</button>
</form>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
</Layouts.authenticated>