CRITICAL: - Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2 - GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy - REST invitations controller: add auth check for invite creation HIGH: - ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4 MEDIUM: - Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp) - Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates) - Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show - Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files LOW: - Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive - Remove redundant Repo.preload calls; add preloads to list_site_devices/1 - Fix @impl annotations and credo nesting warnings
272 lines
12 KiB
Text
272 lines
12 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-2">
|
|
<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>
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@policy.name}</h1>
|
|
</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>
|
|
|
|
<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 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) %>
|
|
<div
|
|
id="level-list"
|
|
class="space-y-3"
|
|
phx-hook="SortableList"
|
|
data-event="reorder_rules"
|
|
>
|
|
<%= for {rule, idx} <- Enum.with_index(sorted_rules) do %>
|
|
<div
|
|
data-id={rule.id}
|
|
draggable="true"
|
|
class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 cursor-move"
|
|
>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<.icon name="hero-bars-3" class="h-4 w-4 text-gray-400" />
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
{t("Level %{n}", n: idx + 1)}
|
|
</h3>
|
|
</div>
|
|
<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("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>
|
|
<%= 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 %>
|
|
|
|
<%!-- 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>
|
|
</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>
|