Performance: - schedule_live: preload page once instead of get_schedule!/1 per row - alert_live + alerts: DB-side status filter; Repo.aggregate counts replace length/Enum.count over 500-row fetches on every event - dashboard_live: drop duplicate get_device_status_counts; cap active alerts to 20 + use count_active_alerts/1 - maintenance.active_windows_for_device: 3 round-trips collapsed into one query (per-site-id branch keeps Postgres parameter types unambiguous) - sites.build_site_tree: O(N^2) -> O(N) via group_by(parent_site_id) - accounts.sole_owner_organizations: single group_by + having instead of per-org Repo.aggregate loop - agents + agent_live (org + admin): count_assigned_devices_batch/1 + count_agent_polling_targets/1 (no preloads) - alert_digest_worker: list_alerts_by_ids/1 batches digest fetch - gaiia: distinct: true at DB; limit 50 on bidirectional ilike Indexes: - maintenance_windows(organization_id, starts_at, ends_at) WHERE suppress_alerts = true - alerts(check_id) WHERE resolved_at IS NULL Antipatterns: - agents.delete_agent_token: PubSub.broadcast moved outside Repo.transaction so a rollback no longer leaves subscribers acting on a non-existent deletion - integrations_controller.to_atom_keys: replaced String.to_existing_atom on user-controlled JSON keys with explicit allowlist - 9 Task.start callsites converted to Task.Supervisor.start_child(Towerops.TaskSupervisor, ...) so background DB writes survive shutdown (test-mode discovery shims left as-is for sandbox semantics)
246 lines
7.3 KiB
Elixir
246 lines
7.3 KiB
Elixir
defmodule ToweropsWeb.ScheduleLive.Index do
|
||
@moduledoc false
|
||
use ToweropsWeb, :live_view
|
||
|
||
alias Towerops.OnCall
|
||
alias Towerops.OnCall.Resolver
|
||
alias Towerops.OnCall.UserColor
|
||
|
||
@valid_ranges [7, 14, 28]
|
||
@default_range 14
|
||
@per_page 10
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
{:ok,
|
||
socket
|
||
|> assign(:page_title, t("Schedules"))
|
||
|> assign(:active_page, "schedules")
|
||
|> assign(:tab, "schedules")
|
||
|> assign(:start_date, default_start_date())
|
||
|> assign(:range_days, @default_range)
|
||
|> assign(:search, "")
|
||
|> assign(:page, 1)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _url, socket) do
|
||
tab = Map.get(params, "tab", "schedules")
|
||
start_date = parse_start_date(params["start"])
|
||
range_days = parse_range_days(params["range"])
|
||
search = params["search"] |> to_string() |> String.trim()
|
||
page = parse_page(params["page"])
|
||
org_id = socket.assigns.current_scope.organization.id
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:tab, tab)
|
||
|> assign(:start_date, start_date)
|
||
|> assign(:range_days, range_days)
|
||
|> assign(:search, search)
|
||
|> assign(:page, page)
|
||
|> load_data(org_id)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("today", _params, socket) do
|
||
{:noreply, navigate_to_window(socket, default_start_date(), socket.assigns.range_days)}
|
||
end
|
||
|
||
def handle_event("prev", _params, socket) do
|
||
new_start = Date.add(socket.assigns.start_date, -socket.assigns.range_days)
|
||
{:noreply, navigate_to_window(socket, new_start, socket.assigns.range_days)}
|
||
end
|
||
|
||
def handle_event("next", _params, socket) do
|
||
new_start = Date.add(socket.assigns.start_date, socket.assigns.range_days)
|
||
{:noreply, navigate_to_window(socket, new_start, socket.assigns.range_days)}
|
||
end
|
||
|
||
def handle_event("set_range", %{"range" => range}, socket) do
|
||
parsed = parse_range_days(range)
|
||
{:noreply, navigate_to_window(socket, socket.assigns.start_date, parsed)}
|
||
end
|
||
|
||
def handle_event("search", %{"search" => search}, socket) do
|
||
overrides = %{search: String.trim(search), page: 1}
|
||
|
||
{:noreply,
|
||
push_patch(socket,
|
||
to: ~p"/schedules?#{base_query_params(socket, overrides)}"
|
||
)}
|
||
end
|
||
|
||
defp navigate_to_window(socket, start_date, range_days) do
|
||
overrides = %{start: Date.to_iso8601(start_date), range: range_days, page: 1}
|
||
|
||
push_patch(socket,
|
||
to: ~p"/schedules?#{base_query_params(socket, overrides)}"
|
||
)
|
||
end
|
||
|
||
defp base_query_params(socket, overrides) do
|
||
%{
|
||
tab: socket.assigns.tab,
|
||
start: Date.to_iso8601(socket.assigns.start_date),
|
||
range: socket.assigns.range_days,
|
||
search: socket.assigns.search,
|
||
page: socket.assigns.page
|
||
}
|
||
|> Map.merge(overrides)
|
||
|> Enum.reject(fn {_k, v} -> v in [nil, "", 1] end)
|
||
|> Map.new()
|
||
end
|
||
|
||
defp load_data(socket, org_id) do
|
||
case socket.assigns.tab do
|
||
"escalation-policies" ->
|
||
policies = OnCall.list_escalation_policies(org_id)
|
||
assign(socket, :policies, policies)
|
||
|
||
_ ->
|
||
all_schedules = OnCall.list_schedules(org_id)
|
||
filtered = filter_schedules(all_schedules, socket.assigns.search)
|
||
total_count = length(filtered)
|
||
total_pages = max(1, ceil(total_count / @per_page))
|
||
page = max(1, min(socket.assigns.page, total_pages))
|
||
|
||
page_schedules =
|
||
filtered
|
||
|> Enum.slice((page - 1) * @per_page, @per_page)
|
||
|> hydrate_schedules(socket.assigns.start_date, socket.assigns.range_days)
|
||
|
||
socket
|
||
|> assign(:schedules, page_schedules)
|
||
|> assign(:page, page)
|
||
|> assign(:pagination, %{
|
||
page: page,
|
||
per_page: @per_page,
|
||
total_count: total_count,
|
||
total_pages: total_pages
|
||
})
|
||
end
|
||
end
|
||
|
||
defp filter_schedules(schedules, ""), do: schedules
|
||
|
||
defp filter_schedules(schedules, term) when is_binary(term) do
|
||
needle = String.downcase(term)
|
||
|
||
Enum.filter(schedules, fn s ->
|
||
String.contains?(String.downcase(s.name || ""), needle)
|
||
end)
|
||
end
|
||
|
||
defp hydrate_schedules(schedules, start_date, range_days) do
|
||
end_date = Date.add(start_date, range_days)
|
||
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
|
||
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
|
||
|
||
preloaded_schedules =
|
||
Towerops.Repo.preload(schedules, overrides: :user, layers: [members: :user])
|
||
|
||
Enum.map(preloaded_schedules, fn preloaded ->
|
||
schedule = preloaded
|
||
on_call = Resolver.resolve(preloaded, DateTime.utc_now())
|
||
|
||
segments =
|
||
preloaded
|
||
|> Resolver.resolve_range(start_at, end_at)
|
||
|> Enum.map(fn segment ->
|
||
Map.merge(segment, %{
|
||
color_class: UserColor.class_for(segment.user),
|
||
label: display_name(segment.user),
|
||
day_offset: day_offset(segment.start_at, start_date),
|
||
day_span: day_span(segment.start_at, segment.end_at)
|
||
})
|
||
end)
|
||
|
||
schedule
|
||
|> Map.put(:current_on_call, on_call)
|
||
|> Map.put(:current_on_call_name, display_name(on_call))
|
||
|> Map.put(:current_on_call_color, UserColor.class_for(on_call))
|
||
|> Map.put(:segments, segments)
|
||
end)
|
||
end
|
||
|
||
defp parse_page(nil), do: 1
|
||
|
||
defp parse_page(value) when is_binary(value) do
|
||
case Integer.parse(value) do
|
||
{int, ""} when int >= 1 -> int
|
||
_ -> 1
|
||
end
|
||
end
|
||
|
||
defp parse_page(value) when is_integer(value) and value >= 1, do: value
|
||
defp parse_page(_), do: 1
|
||
|
||
# Day index (0-based) within the visible window.
|
||
defp day_offset(%DateTime{} = at, %Date{} = window_start) do
|
||
at
|
||
|> DateTime.to_date()
|
||
|> Date.diff(window_start)
|
||
|> max(0)
|
||
end
|
||
|
||
# How many day-columns this segment spans (minimum 1).
|
||
defp day_span(%DateTime{} = start_at, %DateTime{} = end_at) do
|
||
start_date = DateTime.to_date(start_at)
|
||
end_date = DateTime.to_date(end_at)
|
||
end_date |> Date.diff(start_date) |> max(1)
|
||
end
|
||
|
||
def days_in_window(start_date, range_days) do
|
||
Enum.map(0..(range_days - 1), &Date.add(start_date, &1))
|
||
end
|
||
|
||
def window_label(start_date, range_days) do
|
||
end_date = Date.add(start_date, range_days - 1)
|
||
"#{format_short(start_date)} – #{format_short(end_date)}"
|
||
end
|
||
|
||
def today_offset(start_date, range_days) do
|
||
today = Date.utc_today()
|
||
|
||
cond do
|
||
Date.before?(today, start_date) -> nil
|
||
Date.diff(today, start_date) >= range_days -> nil
|
||
true -> Date.diff(today, start_date)
|
||
end
|
||
end
|
||
|
||
defp format_short(date), do: Calendar.strftime(date, "%b %-d")
|
||
|
||
defp default_start_date do
|
||
Date.beginning_of_week(Date.utc_today(), :sunday)
|
||
end
|
||
|
||
defp parse_start_date(nil), do: default_start_date()
|
||
|
||
defp parse_start_date(value) when is_binary(value) do
|
||
case Date.from_iso8601(value) do
|
||
{:ok, date} -> date
|
||
_ -> default_start_date()
|
||
end
|
||
end
|
||
|
||
defp parse_range_days(nil), do: @default_range
|
||
|
||
defp parse_range_days(value) when is_binary(value) do
|
||
case Integer.parse(value) do
|
||
{int, ""} when int in @valid_ranges -> int
|
||
_ -> @default_range
|
||
end
|
||
end
|
||
|
||
defp parse_range_days(value) when is_integer(value) and value in @valid_ranges, do: value
|
||
defp parse_range_days(_), do: @default_range
|
||
|
||
defp display_name(%{first_name: first, last_name: last}) when first != nil and last != nil do
|
||
"#{first} #{last}"
|
||
end
|
||
|
||
defp display_name(%{email: email}), do: email
|
||
defp display_name(nil), do: nil
|
||
end
|