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
197 lines
8.5 KiB
Text
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>
|