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
342 lines
14 KiB
Text
342 lines
14 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"}
|
|
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">{@schedule.name}</h1>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{@schedule.timezone}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<.link
|
|
navigate={~p"/schedules/#{@schedule.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 schedule?")}
|
|
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 @schedule.description do %>
|
|
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@schedule.description}</p>
|
|
<% end %>
|
|
|
|
<%!-- On-Call Status --%>
|
|
<div class="mb-8 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class={[
|
|
"h-10 w-10 rounded-full flex items-center justify-center",
|
|
@on_call && "bg-green-100 dark:bg-green-900/40",
|
|
!@on_call && "bg-gray-100 dark:bg-gray-800"
|
|
]}>
|
|
<.icon
|
|
name="hero-user"
|
|
class={[
|
|
"h-5 w-5",
|
|
@on_call && "text-green-600 dark:text-green-400",
|
|
!@on_call && "text-gray-400 dark:text-gray-500"
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("Currently On-Call")}
|
|
</p>
|
|
<%= if @on_call do %>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{@on_call.email}</p>
|
|
<% else %>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
{t("No one is currently on-call")}
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Layers --%>
|
|
<div class="mb-8">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Layers")}</h2>
|
|
<button
|
|
phx-click="toggle_add_layer"
|
|
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 Layer")}
|
|
</button>
|
|
</div>
|
|
|
|
<%= if @show_add_layer 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 for={@layer_form} phx-change="validate_layer" phx-submit="save_layer">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
|
<.input
|
|
field={@layer_form[:name]}
|
|
type="text"
|
|
label={t("Layer Name")}
|
|
placeholder="Layer 1"
|
|
/>
|
|
<.input
|
|
field={@layer_form[:rotation_type]}
|
|
type="select"
|
|
label={t("Rotation Type")}
|
|
options={[{t("Daily"), "daily"}, {t("Weekly"), "weekly"}, {t("Custom"), "custom"}]}
|
|
/>
|
|
<.input
|
|
field={@layer_form[:rotation_interval]}
|
|
type="number"
|
|
label={t("Rotation Interval")}
|
|
min="1"
|
|
/>
|
|
<.input field={@layer_form[:handoff_time]} type="time" label={t("Handoff Time")} />
|
|
<.input
|
|
field={@layer_form[:handoff_day]}
|
|
type="select"
|
|
label={t("Handoff Day")}
|
|
options={[
|
|
{t("Sunday"), "0"},
|
|
{t("Monday"), "1"},
|
|
{t("Tuesday"), "2"},
|
|
{t("Wednesday"), "3"},
|
|
{t("Thursday"), "4"},
|
|
{t("Friday"), "5"},
|
|
{t("Saturday"), "6"}
|
|
]}
|
|
/>
|
|
<.input
|
|
field={@layer_form[:start_date]}
|
|
type="datetime-local"
|
|
label={t("Start Date")}
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<.button type="submit" phx-disable-with={t("Saving...")}>
|
|
{t("Add Layer")}
|
|
</.button>
|
|
<button
|
|
type="button"
|
|
phx-click="toggle_add_layer"
|
|
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?(@schedule.layers) 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 layers configured. Add a layer to define rotation coverage.")}
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="space-y-4">
|
|
<%= for layer <- Enum.sort_by(@schedule.layers, & &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">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{layer.name}</h3>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval}
|
|
<%= case layer.rotation_type do %>
|
|
<% "daily" -> %>
|
|
{ngettext("day", "days", layer.rotation_interval)}
|
|
<% "weekly" -> %>
|
|
{ngettext("week", "weeks", layer.rotation_interval)}
|
|
<% _ -> %>
|
|
{ngettext("day", "days", layer.rotation_interval)}
|
|
<% end %>
|
|
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
|
|
</p>
|
|
</div>
|
|
<button
|
|
phx-click="delete_layer"
|
|
phx-value-id={layer.id}
|
|
data-confirm={t("Are you sure you want to delete this layer?")}
|
|
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>
|
|
|
|
<%!-- Members --%>
|
|
<div class="mb-2">
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
|
{t("Members")}
|
|
</p>
|
|
<%= if Enum.empty?(layer.members) do %>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
|
|
{t("No members yet")}
|
|
</p>
|
|
<% else %>
|
|
<div class="flex flex-wrap gap-2 mb-2">
|
|
<%= for member <- Enum.sort_by(layer.members, & &1.position) 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">
|
|
{member.user.email}
|
|
<button
|
|
phx-click="remove_member"
|
|
phx-value-id={member.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 member dropdown --%>
|
|
<% existing_user_ids = Enum.map(layer.members, & &1.user_id) %>
|
|
<% available_users = Enum.reject(@org_users, &(&1.id in existing_user_ids)) %>
|
|
<%= unless Enum.empty?(available_users) do %>
|
|
<form phx-submit="add_member" class="flex items-center gap-2">
|
|
<input type="hidden" name="layer_id" value={layer.id} />
|
|
<select
|
|
name="user_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 a member...")}</option>
|
|
<%= for user <- available_users do %>
|
|
<option value={user.id}>{user.email}</option>
|
|
<% end %>
|
|
</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>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Overrides --%>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Overrides")}</h2>
|
|
<button
|
|
phx-click="toggle_add_override"
|
|
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 Override")}
|
|
</button>
|
|
</div>
|
|
|
|
<%= if @show_add_override 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 for={@override_form} phx-change="validate_override" phx-submit="save_override">
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
|
<.input
|
|
field={@override_form[:user_id]}
|
|
type="select"
|
|
label={t("User")}
|
|
options={Enum.map(@org_users, &{&1.email, &1.id})}
|
|
prompt={t("Select user...")}
|
|
/>
|
|
<.input
|
|
field={@override_form[:start_time]}
|
|
type="datetime-local"
|
|
label={t("Start Time")}
|
|
/>
|
|
<.input
|
|
field={@override_form[:end_time]}
|
|
type="datetime-local"
|
|
label={t("End Time")}
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<.button type="submit" phx-disable-with={t("Saving...")}>
|
|
{t("Add Override")}
|
|
</.button>
|
|
<button
|
|
type="button"
|
|
phx-click="toggle_add_override"
|
|
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?(@schedule.overrides) 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 overrides configured.")}
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("User")}
|
|
</th>
|
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("Start")}
|
|
</th>
|
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("End")}
|
|
</th>
|
|
<th class="px-4 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
|
<%= for override <- @schedule.overrides do %>
|
|
<tr>
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
|
|
{override.user.email}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{Calendar.strftime(override.end_time, "%Y-%m-%d %H:%M")}
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<button
|
|
phx-click="delete_override"
|
|
phx-value-id={override.id}
|
|
data-confirm={t("Are you sure?")}
|
|
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>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</Layouts.authenticated>
|