towerops/lib/towerops_web/live/schedule_live/index.ex
Graham McIntire 701ce12f08 perf+refactor: codebase-wide query and antipattern audit
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)
2026-04-28 16:58:51 -05:00

246 lines
7.3 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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