272 lines
11 KiB
Text
272 lines
11 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="schedules"
|
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
|
>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1>
|
|
<div class="flex items-center gap-2">
|
|
<%= if @tab == "escalation-policies" do %>
|
|
<.link
|
|
navigate={~p"/schedules/escalation-policies/new"}
|
|
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
|
>
|
|
<.icon name="hero-plus" class="h-4 w-4" />
|
|
{t("New Policy")}
|
|
</.link>
|
|
<% else %>
|
|
<.link
|
|
navigate={~p"/schedules/new"}
|
|
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
|
>
|
|
<.icon name="hero-plus" class="h-4 w-4" />
|
|
{t("New Schedule")}
|
|
</.link>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
|
{t("Define on-call rotations and escalation policies for alert notifications.")}
|
|
</p>
|
|
|
|
<div class="flex items-center gap-2 mb-6">
|
|
<.link
|
|
patch={~p"/schedules?tab=schedules"}
|
|
class={[
|
|
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
|
@tab == "schedules" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
|
@tab != "schedules" &&
|
|
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
]}
|
|
>
|
|
{t("Schedules")}
|
|
</.link>
|
|
<.link
|
|
patch={~p"/schedules?tab=escalation-policies"}
|
|
class={[
|
|
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
|
@tab == "escalation-policies" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
|
@tab != "escalation-policies" &&
|
|
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
]}
|
|
>
|
|
{t("Escalation Policies")}
|
|
</.link>
|
|
</div>
|
|
|
|
<%= if @tab == "escalation-policies" do %>
|
|
<%= if Enum.empty?(@policies) do %>
|
|
<div class="flex items-center justify-center py-16">
|
|
<div class="text-center">
|
|
<.icon
|
|
name="hero-arrow-trending-up"
|
|
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
|
/>
|
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
|
{t("No escalation policies")}
|
|
</h3>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Create an escalation policy to define how alerts are escalated to on-call users.")}
|
|
</p>
|
|
</div>
|
|
</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("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("Levels")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
|
<%= for policy <- @policies do %>
|
|
<tr
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
|
phx-click={JS.navigate(~p"/schedules/escalation-policies/#{policy.id}")}
|
|
>
|
|
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
|
{policy.name}
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
{length(policy.rules)}
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
<% else %>
|
|
<%!-- Search input --%>
|
|
<form id="schedule-search-form" phx-change="search" phx-submit="search" class="mb-4">
|
|
<div class="relative max-w-md">
|
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
|
|
<.icon name="hero-magnifying-glass" class="h-4 w-4 text-gray-400" />
|
|
</span>
|
|
<input
|
|
type="text"
|
|
name="search"
|
|
value={@search}
|
|
placeholder={t("Search schedules…")}
|
|
phx-debounce="300"
|
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm pl-9 py-1.5"
|
|
/>
|
|
</div>
|
|
</form>
|
|
|
|
<%!-- Date navigation header --%>
|
|
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
phx-click="today"
|
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
>
|
|
{t("Today")}
|
|
</button>
|
|
<button
|
|
phx-click="prev"
|
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
title={t("Previous range")}
|
|
>
|
|
<.icon name="hero-chevron-left" class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
phx-click="next"
|
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
title={t("Next range")}
|
|
>
|
|
<.icon name="hero-chevron-right" class="h-4 w-4" />
|
|
</button>
|
|
<span class="ml-2 text-sm font-semibold text-gray-900 dark:text-white">
|
|
{window_label(@start_date, @range_days)}
|
|
</span>
|
|
</div>
|
|
<form id="range-form" phx-change="set_range" class="inline-flex">
|
|
<select
|
|
name="range"
|
|
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
|
|
>
|
|
<option value="7" selected={@range_days == 7}>{t("1 Week")}</option>
|
|
<option value="14" selected={@range_days == 14}>{t("2 Weeks")}</option>
|
|
<option value="28" selected={@range_days == 28}>{t("4 Weeks")}</option>
|
|
</select>
|
|
</form>
|
|
</div>
|
|
|
|
<%= if Enum.empty?(@schedules) do %>
|
|
<div class="flex items-center justify-center py-16">
|
|
<div class="text-center">
|
|
<.icon
|
|
name="hero-calendar-days"
|
|
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
|
/>
|
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
|
{t("No schedules")}
|
|
</h3>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Create an on-call schedule to define rotation coverage.")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="space-y-4">
|
|
<%= for schedule <- @schedules do %>
|
|
<% days = days_in_window(@start_date, @range_days) %>
|
|
<% today_idx = today_offset(@start_date, @range_days) %>
|
|
<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>
|
|
<.link
|
|
navigate={~p"/schedules/#{schedule.id}"}
|
|
class="text-base font-semibold text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
{schedule.name}
|
|
</.link>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{schedule.timezone}
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{t("On-Call Now")}</p>
|
|
<%= if schedule.current_on_call do %>
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<span class={[
|
|
"h-2 w-2 rounded-full",
|
|
schedule.current_on_call_color
|
|
]}></span>
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{schedule.current_on_call_name}
|
|
</span>
|
|
</span>
|
|
<% else %>
|
|
<span class="text-sm text-gray-400 dark:text-gray-500">{t("No one")}</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Day header row --%>
|
|
<div
|
|
class="grid gap-px text-xs text-gray-500 dark:text-gray-400 mb-1"
|
|
style={"grid-template-columns: repeat(#{@range_days}, minmax(0, 1fr));"}
|
|
>
|
|
<%= for {day, idx} <- Enum.with_index(days) do %>
|
|
<div class={[
|
|
"px-1 py-0.5 text-center",
|
|
today_idx == idx && "font-bold text-red-600 dark:text-red-400"
|
|
]}>
|
|
<div class="leading-tight">{Calendar.strftime(day, "%a")}</div>
|
|
<div class="leading-tight">{Calendar.strftime(day, "%-d")}</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Gantt strip --%>
|
|
<div
|
|
class="relative grid gap-px h-8 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-800"
|
|
style={"grid-template-columns: repeat(#{@range_days}, minmax(0, 1fr));"}
|
|
>
|
|
<%= for segment <- schedule.segments do %>
|
|
<div
|
|
class={[
|
|
"flex items-center justify-center text-white text-xs font-medium truncate px-1",
|
|
segment.color_class
|
|
]}
|
|
style={"grid-column: #{segment.day_offset + 1} / span #{segment.day_span};"}
|
|
title={segment.label}
|
|
>
|
|
{segment.label}
|
|
</div>
|
|
<% end %>
|
|
<%= if today_idx do %>
|
|
<div
|
|
class="pointer-events-none absolute top-0 bottom-0 w-px bg-red-500"
|
|
style={"left: calc((100% / #{@range_days}) * #{today_idx});"}
|
|
>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<.pagination
|
|
meta={@pagination}
|
|
path={~p"/schedules"}
|
|
params={
|
|
%{
|
|
"tab" => @tab,
|
|
"start" => Date.to_iso8601(@start_date),
|
|
"range" => Integer.to_string(@range_days),
|
|
"search" => @search
|
|
}
|
|
}
|
|
/>
|
|
<% end %>
|
|
<% end %>
|
|
</Layouts.authenticated>
|