Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
201 lines
5.5 KiB
Elixir
201 lines
5.5 KiB
Elixir
defmodule Towerops.Reports do
|
|
@moduledoc """
|
|
Context for managing scheduled reports.
|
|
|
|
Handles CRUD operations and report generation/delivery.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Alerts
|
|
alias Towerops.Capacity
|
|
alias Towerops.Devices
|
|
alias Towerops.Repo
|
|
alias Towerops.Reports.Report
|
|
alias Towerops.RfLinks
|
|
|
|
require Logger
|
|
|
|
# -- CRUD --
|
|
|
|
def list_reports(organization_id) do
|
|
Report
|
|
|> where(organization_id: ^organization_id)
|
|
|> order_by(desc: :inserted_at)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def get_report!(id), do: Repo.get!(Report, id)
|
|
|
|
def get_report(id) do
|
|
case Repo.get(Report, id) do
|
|
nil -> {:error, :not_found}
|
|
report -> {:ok, report}
|
|
end
|
|
end
|
|
|
|
def create_report(attrs) do
|
|
%Report{}
|
|
|> Report.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
def update_report(%Report{} = report, attrs) do
|
|
report
|
|
|> Report.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
def delete_report(%Report{} = report) do
|
|
Repo.delete(report)
|
|
end
|
|
|
|
def toggle_report(%Report{} = report) do
|
|
update_report(report, %{enabled: !report.enabled})
|
|
end
|
|
|
|
# -- Report Generation --
|
|
|
|
@doc """
|
|
Generates report data as a CSV string based on report type and scope.
|
|
"""
|
|
def generate_csv(%Report{} = report) do
|
|
org_id = report.organization_id
|
|
time_range = parse_time_range(report.scope)
|
|
|
|
case report.report_type do
|
|
"uptime_summary" -> generate_uptime_csv(org_id, time_range)
|
|
"alert_history" -> generate_alert_history_csv(org_id, time_range)
|
|
"capacity_trends" -> generate_capacity_csv(org_id)
|
|
"rf_link_health" -> generate_rf_link_csv(org_id)
|
|
end
|
|
end
|
|
|
|
defp generate_uptime_csv(org_id, _time_range) do
|
|
devices = Devices.list_organization_devices(org_id)
|
|
|
|
header = "Device,IP Address,Status,Last Seen\n"
|
|
|
|
rows =
|
|
Enum.map_join(devices, "\n", fn device ->
|
|
status = if device.monitoring_enabled, do: "monitored", else: "unmonitored"
|
|
"#{csv_escape(device.name)},#{device.ip_address},#{status},#{device.updated_at}"
|
|
end)
|
|
|
|
{:ok, header <> rows}
|
|
end
|
|
|
|
defp generate_alert_history_csv(org_id, _time_range) do
|
|
alerts = Alerts.list_organization_alerts(org_id, 500)
|
|
|
|
header = "Alert,Severity,Status,Device,Created At,Resolved At\n"
|
|
|
|
rows =
|
|
Enum.map_join(alerts, "\n", fn alert ->
|
|
device_name = if alert.device, do: alert.device.name, else: "-"
|
|
resolved = if alert.resolved_at, do: to_string(alert.resolved_at), else: "-"
|
|
|
|
"#{csv_escape(alert.message)},#{alert.severity},#{alert.status},#{csv_escape(device_name)},#{alert.inserted_at},#{resolved}"
|
|
end)
|
|
|
|
{:ok, header <> rows}
|
|
end
|
|
|
|
defp generate_capacity_csv(org_id) do
|
|
summaries = Capacity.get_organization_capacity_summary(org_id)
|
|
|
|
header = "Site,Total Capacity (bps),Throughput (bps),Utilization %\n"
|
|
|
|
rows =
|
|
Enum.map_join(summaries, "\n", fn s ->
|
|
"#{csv_escape(s.site_name || "Unknown")},#{s.total_capacity_bps},#{s.total_throughput_bps},#{s.utilization_pct}"
|
|
end)
|
|
|
|
{:ok, header <> rows}
|
|
end
|
|
|
|
defp generate_rf_link_csv(org_id) do
|
|
links = RfLinks.list_rf_links(org_id)
|
|
|
|
header = "Client,MAC,Signal (dBm),SNR (dB),TX Rate,RX Rate,Health,Last Seen\n"
|
|
|
|
rows =
|
|
Enum.map_join(links, "\n", fn link ->
|
|
name = link.hostname || link.ip_address || link.mac_address || "-"
|
|
|
|
"#{csv_escape(name)},#{link.mac_address},#{link.signal_strength || "-"},#{link.snr || "-"},#{link.tx_rate || "-"},#{link.rx_rate || "-"},#{link.health},#{link.last_seen_at}"
|
|
end)
|
|
|
|
{:ok, header <> rows}
|
|
end
|
|
|
|
@doc """
|
|
Escapes a value for inclusion in a CSV cell.
|
|
|
|
Wraps values containing commas, quotes, or newlines in double-quotes and
|
|
doubles any embedded quotes. `nil` becomes the empty string; non-binary
|
|
non-nil values are coerced via `to_string/1`.
|
|
"""
|
|
@spec csv_escape(term()) :: String.t()
|
|
def csv_escape(nil), do: ""
|
|
|
|
def csv_escape(value) when is_binary(value) do
|
|
if String.contains?(value, [",", "\"", "\n"]) do
|
|
"\"#{String.replace(value, "\"", "\"\"")}\""
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
def csv_escape(value), do: to_string(value)
|
|
|
|
@doc """
|
|
Parses the `days` field of a report's scope map into a `%{from, to}` range
|
|
ending at `DateTime.utc_now/0`. Defaults to 7 days when `days` is missing.
|
|
"""
|
|
@spec parse_time_range(map()) :: %{from: DateTime.t(), to: DateTime.t()}
|
|
def parse_time_range(scope) when is_map(scope) do
|
|
days = Map.get(scope, "days", 7)
|
|
now = DateTime.utc_now()
|
|
%{from: DateTime.add(now, -days, :day), to: now}
|
|
end
|
|
|
|
# -- Schedule Helpers --
|
|
|
|
@doc """
|
|
Returns true if a report is due to run based on its schedule.
|
|
"""
|
|
def due?(%Report{schedule: %{"type" => "one_time"}} = report) do
|
|
is_nil(report.last_run_at)
|
|
end
|
|
|
|
def due?(%Report{schedule: %{"type" => "daily"}} = report) do
|
|
elapsed_since_last_run(report) >= 86_400
|
|
end
|
|
|
|
def due?(%Report{schedule: %{"type" => "weekly"}} = report) do
|
|
elapsed_since_last_run(report) >= 604_800
|
|
end
|
|
|
|
def due?(%Report{schedule: %{"type" => "monthly"}} = report) do
|
|
elapsed_since_last_run(report) >= 2_592_000
|
|
end
|
|
|
|
def due?(_), do: false
|
|
|
|
defp elapsed_since_last_run(%{last_run_at: nil}), do: :infinity
|
|
|
|
defp elapsed_since_last_run(%{last_run_at: last_run_at}) do
|
|
DateTime.diff(DateTime.utc_now(), last_run_at, :second)
|
|
end
|
|
|
|
@doc """
|
|
Marks a report as having been run.
|
|
"""
|
|
def mark_run(%Report{} = report, status) do
|
|
update_report(report, %{
|
|
last_run_at: Towerops.Time.now(),
|
|
last_run_status: status
|
|
})
|
|
end
|
|
end
|