fix: make sidebar sticky with header and resolve all credo issues (#32)
- Changed sidebar from fixed to sticky positioning - Wrapped sidebar and main content in flex container - Sidebar now sticks to top along with header when scrolling - Removed unused Ecto.Query import from gps_sync.ex Credo improvements (17 → 0 remaining): - Replaced all length/1 checks with empty list comparisons - Extracted helper functions to reduce nesting depth - Split complex functions into smaller, testable units - Applied 'with' statements for clearer control flow - Refactored high-complexity functions (cyclomatic complexity) - Files improved: storm_detector, alert_notification_worker, alert_digest_worker, gps_sync, statistics_sync, device_sync, site_sync, site_correlation, reports_live, topology, pagerduty/client, cn_maestro/sync Reviewed-on: graham/towerops-web#32
This commit is contained in:
parent
42bfc8b9d2
commit
7c8731f86a
31 changed files with 914 additions and 756 deletions
|
|
@ -56,25 +56,32 @@ defmodule Towerops.Alerts.SiteCorrelation do
|
|||
{:site_outage, outage}
|
||||
|
||||
nil ->
|
||||
# Count recent device_down alerts at this site
|
||||
recent_down_count = count_recent_site_alerts(device.site_id, cutoff)
|
||||
check_and_create_site_outage(device, cutoff, now, threshold)
|
||||
end
|
||||
end
|
||||
|
||||
# +1 for the current device about to go down
|
||||
if recent_down_count + 1 >= threshold do
|
||||
# Threshold met — create site outage
|
||||
case create_site_outage(device, now, recent_down_count + 1) do
|
||||
{:ok, outage} ->
|
||||
# Retroactively link existing alerts to this outage
|
||||
link_existing_alerts(device.site_id, cutoff, outage.id)
|
||||
{:site_outage, outage}
|
||||
defp check_and_create_site_outage(device, cutoff, now, threshold) do
|
||||
# Count recent device_down alerts at this site
|
||||
recent_down_count = count_recent_site_alerts(device.site_id, cutoff)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create site outage: #{inspect(reason)}")
|
||||
:no_correlation
|
||||
end
|
||||
else
|
||||
:no_correlation
|
||||
end
|
||||
# +1 for the current device about to go down
|
||||
if recent_down_count + 1 >= threshold do
|
||||
handle_site_outage_creation(device, now, recent_down_count + 1, cutoff)
|
||||
else
|
||||
:no_correlation
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_site_outage_creation(device, now, count, cutoff) do
|
||||
case create_site_outage(device, now, count) do
|
||||
{:ok, outage} ->
|
||||
# Retroactively link existing alerts to this outage
|
||||
link_existing_alerts(device.site_id, cutoff, outage.id)
|
||||
{:site_outage, outage}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create site outage: #{inspect(reason)}")
|
||||
:no_correlation
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -242,85 +242,92 @@ defmodule Towerops.Alerts.StormDetector do
|
|||
if Maintenance.site_in_maintenance?(site_id) do
|
||||
Logger.info("Site #{site_id} outage (#{device_count} devices) suppressed — in maintenance window")
|
||||
else
|
||||
# Get site name for the alert message
|
||||
site_name =
|
||||
case Towerops.Sites.get_site(site_id) do
|
||||
nil -> "Unknown site"
|
||||
site -> site.name
|
||||
end
|
||||
do_create_site_outage_alert(site_id, devices, device_names, device_count, storm_mode, now)
|
||||
end
|
||||
|
||||
# Build impact summary
|
||||
message =
|
||||
"Site outage: #{site_name} — #{device_count} devices down (#{Enum.join(Enum.take(device_names, 5), ", ")}#{if device_count > 5, do: " and #{device_count - 5} more", else: ""})"
|
||||
# Also mark each individual device as down (status update only, no individual alerts)
|
||||
Enum.each(devices, fn device ->
|
||||
Devices.update_device_status(device, :down)
|
||||
end)
|
||||
end
|
||||
|
||||
# Use the first device as the "primary" for the alert record
|
||||
# but store all affected device IDs in metadata
|
||||
primary_device = List.first(devices)
|
||||
|
||||
case Alerts.create_alert(%{
|
||||
device_id: primary_device.id,
|
||||
alert_type: "site_outage",
|
||||
triggered_at: now,
|
||||
message: message,
|
||||
storm_suppressed: false
|
||||
}) do
|
||||
{:ok, alert} ->
|
||||
# Single notification instead of N
|
||||
if !storm_mode do
|
||||
AlertNotificationWorker.enqueue_trigger(alert.id)
|
||||
end
|
||||
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{primary_device.organization_id}:new",
|
||||
{:new_alert, primary_device.id, :site_outage}
|
||||
)
|
||||
|
||||
Logger.warning(
|
||||
"Created site outage alert for #{site_name}: #{device_count} devices",
|
||||
site_id: site_id,
|
||||
device_count: device_count,
|
||||
storm_mode: storm_mode
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create site outage alert: #{inspect(reason)}")
|
||||
defp do_create_site_outage_alert(site_id, devices, device_names, device_count, storm_mode, now) do
|
||||
# Get site name for the alert message
|
||||
site_name =
|
||||
case Towerops.Sites.get_site(site_id) do
|
||||
nil -> "Unknown site"
|
||||
site -> site.name
|
||||
end
|
||||
|
||||
# Also mark each individual device as down (status update only, no individual alerts)
|
||||
Enum.each(devices, fn device ->
|
||||
Devices.update_device_status(device, :down)
|
||||
# Build impact summary
|
||||
message =
|
||||
"Site outage: #{site_name} — #{device_count} devices down (#{Enum.join(Enum.take(device_names, 5), ", ")}#{if device_count > 5, do: " and #{device_count - 5} more", else: ""})"
|
||||
|
||||
# Suppress individual device_down alerts — the site_outage covers them
|
||||
# But DO create suppressed alert records for audit trail
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: now,
|
||||
resolved_at: now,
|
||||
message: "#{device.name} is down (suppressed — part of site outage at #{site_name})",
|
||||
storm_suppressed: true
|
||||
})
|
||||
end)
|
||||
# Use the first device as the "primary" for the alert record
|
||||
primary_device = List.first(devices)
|
||||
|
||||
case Alerts.create_alert(%{
|
||||
device_id: primary_device.id,
|
||||
alert_type: "site_outage",
|
||||
triggered_at: now,
|
||||
message: message,
|
||||
storm_suppressed: false
|
||||
}) do
|
||||
{:ok, alert} ->
|
||||
if !storm_mode do
|
||||
AlertNotificationWorker.enqueue_trigger(alert.id)
|
||||
end
|
||||
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{primary_device.organization_id}:new",
|
||||
{:new_alert, primary_device.id, :site_outage}
|
||||
)
|
||||
|
||||
Logger.warning(
|
||||
"Created site outage alert for #{site_name}: #{device_count} devices",
|
||||
site_id: site_id,
|
||||
device_count: device_count,
|
||||
storm_mode: storm_mode
|
||||
)
|
||||
|
||||
# Create suppressed alert records for individual devices (for audit trail)
|
||||
Enum.each(devices, fn device ->
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: "device_down",
|
||||
triggered_at: now,
|
||||
resolved_at: now,
|
||||
message: "#{device.name} is down (suppressed — part of site outage at #{site_name})",
|
||||
storm_suppressed: true
|
||||
})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create site outage alert: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp create_individual_alerts(events, storm_mode) do
|
||||
Enum.each(events, fn %{device: device, timestamp: timestamp} ->
|
||||
# Skip if already has an active alert
|
||||
if Alerts.has_active_alert?(device.id, "device_down") do
|
||||
:ok
|
||||
else
|
||||
if Maintenance.device_in_maintenance?(device.id) do
|
||||
Logger.info("Device #{device.id} (#{device.name}) is down but in maintenance — suppressing")
|
||||
else
|
||||
create_single_device_down_alert(device, timestamp, storm_mode)
|
||||
end
|
||||
end
|
||||
process_device_alert(device, timestamp, storm_mode)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_device_alert(device, timestamp, storm_mode) do
|
||||
cond do
|
||||
Alerts.has_active_alert?(device.id, "device_down") ->
|
||||
:ok
|
||||
|
||||
Maintenance.device_in_maintenance?(device.id) ->
|
||||
Logger.info("Device #{device.id} (#{device.name}) is down but in maintenance — suppressing")
|
||||
|
||||
true ->
|
||||
create_single_device_down_alert(device, timestamp, storm_mode)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_single_device_down_alert(device, now, storm_mode) do
|
||||
alert_message =
|
||||
if device.snmp_enabled do
|
||||
|
|
|
|||
|
|
@ -60,27 +60,8 @@ defmodule Towerops.CnMaestro.Sync do
|
|||
case Client.list_networks(base_url, token) do
|
||||
{:ok, networks} ->
|
||||
{synced, site_map} =
|
||||
Enum.reduce(networks, {0, %{}}, fn network, {count, acc} ->
|
||||
name = network["name"]
|
||||
network_id = network["id"] || network["name"]
|
||||
|
||||
if is_nil(name) or name == "" do
|
||||
{count, acc}
|
||||
else
|
||||
attrs = %{
|
||||
organization_id: org_id,
|
||||
name: name
|
||||
}
|
||||
|
||||
case upsert_site(attrs) do
|
||||
{:ok, site} ->
|
||||
new_acc = if network_id, do: Map.put(acc, network_id, site), else: acc
|
||||
{count + 1, new_acc}
|
||||
|
||||
{:error, _} ->
|
||||
{count, acc}
|
||||
end
|
||||
end
|
||||
Enum.reduce(networks, {0, %{}}, fn network, acc ->
|
||||
process_network(network, org_id, acc)
|
||||
end)
|
||||
|
||||
{:ok, %{synced: synced, site_map: site_map}}
|
||||
|
|
@ -90,32 +71,36 @@ defmodule Towerops.CnMaestro.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp process_network(network, org_id, {count, acc}) do
|
||||
name = network["name"]
|
||||
network_id = network["id"] || network["name"]
|
||||
|
||||
if is_nil(name) or name == "" do
|
||||
{count, acc}
|
||||
else
|
||||
upsert_network_site(org_id, name, network_id, count, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp upsert_network_site(org_id, name, network_id, count, acc) do
|
||||
attrs = %{organization_id: org_id, name: name}
|
||||
|
||||
case upsert_site(attrs) do
|
||||
{:ok, site} ->
|
||||
new_acc = if network_id, do: Map.put(acc, network_id, site), else: acc
|
||||
{count + 1, new_acc}
|
||||
|
||||
{:error, _} ->
|
||||
{count, acc}
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_devices(base_url, token, org_id, site_map) do
|
||||
case Client.list_devices(base_url, token) do
|
||||
{:ok, devices} ->
|
||||
result =
|
||||
Enum.reduce(devices, %{matched: 0, created: 0}, fn device_data, acc ->
|
||||
mac = device_data["mac"]
|
||||
ip = device_data["ip"]
|
||||
name = device_data["name"] || device_data["mac"]
|
||||
network = device_data["network"]
|
||||
local_site = site_map[network]
|
||||
|
||||
if is_nil(ip) and is_nil(mac) do
|
||||
acc
|
||||
else
|
||||
case find_existing_device(org_id, ip, mac) do
|
||||
nil ->
|
||||
case create_device(org_id, ip || mac, name, local_site) do
|
||||
{:ok, _} -> Map.update!(acc, :created, &(&1 + 1))
|
||||
{:error, _} -> acc
|
||||
end
|
||||
|
||||
device ->
|
||||
maybe_update_site(device, local_site)
|
||||
Map.update!(acc, :matched, &(&1 + 1))
|
||||
end
|
||||
end
|
||||
process_device(device_data, org_id, site_map, acc)
|
||||
end)
|
||||
|
||||
{:ok, result}
|
||||
|
|
@ -125,6 +110,34 @@ defmodule Towerops.CnMaestro.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp process_device(device_data, org_id, site_map, acc) do
|
||||
mac = device_data["mac"]
|
||||
ip = device_data["ip"]
|
||||
name = device_data["name"] || device_data["mac"]
|
||||
network = device_data["network"]
|
||||
local_site = site_map[network]
|
||||
|
||||
if is_nil(ip) and is_nil(mac) do
|
||||
acc
|
||||
else
|
||||
sync_single_device(org_id, ip, mac, name, local_site, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_single_device(org_id, ip, mac, name, local_site, acc) do
|
||||
case find_existing_device(org_id, ip, mac) do
|
||||
nil ->
|
||||
case create_device(org_id, ip || mac, name, local_site) do
|
||||
{:ok, _} -> Map.update!(acc, :created, &(&1 + 1))
|
||||
{:error, _} -> acc
|
||||
end
|
||||
|
||||
device ->
|
||||
maybe_update_site(device, local_site)
|
||||
Map.update!(acc, :matched, &(&1 + 1))
|
||||
end
|
||||
end
|
||||
|
||||
defp find_existing_device(org_id, ip, _mac) when not is_nil(ip) do
|
||||
Device
|
||||
|> where(organization_id: ^org_id, ip_address: ^ip)
|
||||
|
|
@ -150,10 +163,10 @@ defmodule Towerops.CnMaestro.Sync do
|
|||
defp maybe_update_site(device, nil), do: {:ok, device}
|
||||
|
||||
defp maybe_update_site(device, site) do
|
||||
if device.site_id != site.id do
|
||||
device |> Device.changeset(%{site_id: site.id}) |> Repo.update()
|
||||
else
|
||||
if device.site_id == site.id do
|
||||
{:ok, device}
|
||||
else
|
||||
device |> Device.changeset(%{site_id: site.id}) |> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -98,23 +98,7 @@ defmodule Towerops.PagerDuty.Client do
|
|||
|
||||
{:ok, %{status: 429} = response} ->
|
||||
# Respect Retry-After header if present
|
||||
retry_after =
|
||||
case Map.get(response, :headers) do
|
||||
headers when is_map(headers) ->
|
||||
case Map.get(headers, "retry-after") do
|
||||
val when is_binary(val) -> String.to_integer(val) * 1000
|
||||
_ -> backoff_ms(retries)
|
||||
end
|
||||
|
||||
headers when is_list(headers) ->
|
||||
case List.keyfind(headers, "retry-after", 0) do
|
||||
{_, val} -> String.to_integer(val) * 1000
|
||||
_ -> backoff_ms(retries)
|
||||
end
|
||||
|
||||
_ ->
|
||||
backoff_ms(retries)
|
||||
end
|
||||
retry_after = extract_retry_after(response, retries)
|
||||
|
||||
Logger.info("PagerDuty rate limited, retrying in #{retry_after}ms (attempt #{retries + 1}/#{@max_retries})")
|
||||
Process.sleep(retry_after)
|
||||
|
|
@ -129,6 +113,25 @@ defmodule Towerops.PagerDuty.Client do
|
|||
end
|
||||
end
|
||||
|
||||
defp extract_retry_after(response, retries) do
|
||||
case Map.get(response, :headers) do
|
||||
headers when is_map(headers) ->
|
||||
case Map.get(headers, "retry-after") do
|
||||
val when is_binary(val) -> String.to_integer(val) * 1000
|
||||
_ -> backoff_ms(retries)
|
||||
end
|
||||
|
||||
headers when is_list(headers) ->
|
||||
case List.keyfind(headers, "retry-after", 0) do
|
||||
{_, val} -> String.to_integer(val) * 1000
|
||||
_ -> backoff_ms(retries)
|
||||
end
|
||||
|
||||
_ ->
|
||||
backoff_ms(retries)
|
||||
end
|
||||
end
|
||||
|
||||
# Exponential backoff: 1s, 2s, 4s
|
||||
defp backoff_ms(retries), do: to_timeout(second: round(:math.pow(2, retries)))
|
||||
|
||||
|
|
|
|||
|
|
@ -68,14 +68,18 @@ defmodule Towerops.Reports.Report do
|
|||
|
||||
defp validate_recipients(changeset) do
|
||||
case get_field(changeset, :recipients) do
|
||||
[] -> add_error(changeset, :recipients, "must include at least one recipient")
|
||||
[] ->
|
||||
add_error(changeset, :recipients, "must include at least one recipient")
|
||||
|
||||
recipients when is_list(recipients) ->
|
||||
if Enum.all?(recipients, &valid_email?/1) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :recipients, "all recipients must be valid email addresses")
|
||||
end
|
||||
_ -> changeset
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -118,8 +118,12 @@ defmodule Towerops.RfLinks do
|
|||
max_rate = get_in(metadata || %{}, ["max_rate"])
|
||||
|
||||
cond do
|
||||
is_nil(max_rate) or max_rate == 0 -> nil
|
||||
is_nil(tx) and is_nil(rx) -> nil
|
||||
is_nil(max_rate) or max_rate == 0 ->
|
||||
nil
|
||||
|
||||
is_nil(tx) and is_nil(rx) ->
|
||||
nil
|
||||
|
||||
true ->
|
||||
current = max(tx || 0, rx || 0)
|
||||
Float.round(current / max_rate * 100, 1)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule Towerops.StatusPages.StatusIncident do
|
|||
A status page incident — created manually or auto-generated from alerts.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule Towerops.StatusPages.StatusPageComponent do
|
|||
Maps device groups to customer-facing service names.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule Towerops.StatusPages.StatusPageConfig do
|
|||
One-to-one with organization.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
|
|
@ -27,7 +28,16 @@ defmodule Towerops.StatusPages.StatusPageConfig do
|
|||
|
||||
def changeset(config, attrs) do
|
||||
config
|
||||
|> cast(attrs, [:enabled, :slug, :company_name, :logo_url, :support_email, :custom_css, :auto_incidents, :organization_id])
|
||||
|> cast(attrs, [
|
||||
:enabled,
|
||||
:slug,
|
||||
:company_name,
|
||||
:logo_url,
|
||||
:support_email,
|
||||
:custom_css,
|
||||
:auto_incidents,
|
||||
:organization_id
|
||||
])
|
||||
|> validate_required([:slug, :organization_id])
|
||||
|> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, message: "must be lowercase alphanumeric with hyphens")
|
||||
|> validate_length(:slug, min: 3, max: 63)
|
||||
|
|
|
|||
|
|
@ -1249,17 +1249,17 @@ defmodule Towerops.Topology do
|
|||
|
||||
results
|
||||
|> Enum.group_by(fn {device_id, _val, _descr} -> device_id end)
|
||||
|> Map.new(fn {device_id, entries} ->
|
||||
avg_snr =
|
||||
entries
|
||||
|> Enum.map(fn {_, val, _} -> val end)
|
||||
|> then(fn vals -> Enum.sum(vals) / max(length(vals), 1) end)
|
||||
|
||||
{device_id, %{snr: Float.round(avg_snr, 1), signal_health: classify_snr_health(avg_snr)}}
|
||||
end)
|
||||
|> Map.new(&compute_device_snr_stats/1)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_device_snr_stats({device_id, entries}) do
|
||||
vals = Enum.map(entries, fn {_, val, _} -> val end)
|
||||
avg_snr = Enum.sum(vals) / max(length(vals), 1)
|
||||
|
||||
{device_id, %{snr: Float.round(avg_snr, 1), signal_health: classify_snr_health(avg_snr)}}
|
||||
end
|
||||
|
||||
defp classify_signal_health(nil), do: nil
|
||||
|
||||
defp classify_signal_health(avg_signal) do
|
||||
|
|
@ -1277,29 +1277,35 @@ defmodule Towerops.Topology do
|
|||
defp classify_snr_health(_snr), do: "critical"
|
||||
|
||||
defp enrich_edges_with_rf(edges, rf_stats) do
|
||||
Enum.map(edges, fn edge ->
|
||||
source_rf = Map.get(rf_stats, edge.source)
|
||||
target_rf = Map.get(rf_stats, edge.target)
|
||||
Enum.map(edges, &enrich_single_edge(&1, rf_stats))
|
||||
end
|
||||
|
||||
# Use the worse health of the two endpoints
|
||||
signal_health =
|
||||
case {source_rf, target_rf} do
|
||||
{nil, nil} -> nil
|
||||
{s, nil} -> s.signal_health
|
||||
{nil, t} -> t.signal_health
|
||||
{s, t} -> worse_health(s.signal_health, t.signal_health)
|
||||
end
|
||||
defp enrich_single_edge(edge, rf_stats) do
|
||||
source_rf = Map.get(rf_stats, edge.source)
|
||||
target_rf = Map.get(rf_stats, edge.target)
|
||||
|
||||
snr =
|
||||
case {source_rf, target_rf} do
|
||||
{nil, nil} -> nil
|
||||
{s, nil} -> s[:snr]
|
||||
{nil, t} -> t[:snr]
|
||||
{s, t} -> min(s[:snr] || 0, t[:snr] || 0)
|
||||
end
|
||||
signal_health = compute_edge_signal_health(source_rf, target_rf)
|
||||
snr = compute_edge_snr(source_rf, target_rf)
|
||||
|
||||
Map.merge(edge, %{signal_health: signal_health, snr: snr})
|
||||
end)
|
||||
Map.merge(edge, %{signal_health: signal_health, snr: snr})
|
||||
end
|
||||
|
||||
defp compute_edge_signal_health(source_rf, target_rf) do
|
||||
case {source_rf, target_rf} do
|
||||
{nil, nil} -> nil
|
||||
{s, nil} -> s.signal_health
|
||||
{nil, t} -> t.signal_health
|
||||
{s, t} -> worse_health(s.signal_health, t.signal_health)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_edge_snr(source_rf, target_rf) do
|
||||
case {source_rf, target_rf} do
|
||||
{nil, nil} -> nil
|
||||
{s, nil} -> s[:snr]
|
||||
{nil, t} -> t[:snr]
|
||||
{s, t} -> min(s[:snr] || 0, t[:snr] || 0)
|
||||
end
|
||||
end
|
||||
|
||||
defp worse_health(a, b) do
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
|
||||
defp store_if_changed(device, config_data, acc) do
|
||||
config_json = Jason.encode!(config_data)
|
||||
config_hash = :crypto.hash(:sha256, config_json) |> Base.encode16(case: :lower)
|
||||
config_hash = :sha256 |> :crypto.hash(config_json) |> Base.encode16(case: :lower)
|
||||
|
||||
latest = get_latest_snapshot(device.id)
|
||||
|
||||
|
|
@ -73,38 +73,40 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
Gets the latest config snapshot for a device.
|
||||
"""
|
||||
def get_latest_snapshot(device_id) do
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.device_id == ^device_id,
|
||||
order_by: [desc: s.snapshot_at],
|
||||
limit: 1,
|
||||
select: %{
|
||||
id: s.id,
|
||||
device_id: s.device_id,
|
||||
config_hash: s.config_hash,
|
||||
config_size_bytes: s.config_size_bytes,
|
||||
snapshot_at: s.snapshot_at
|
||||
}
|
||||
Repo.one(
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.device_id == ^device_id,
|
||||
order_by: [desc: s.snapshot_at],
|
||||
limit: 1,
|
||||
select: %{
|
||||
id: s.id,
|
||||
device_id: s.device_id,
|
||||
config_hash: s.config_hash,
|
||||
config_size_bytes: s.config_size_bytes,
|
||||
snapshot_at: s.snapshot_at
|
||||
}
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists config snapshots for a device, most recent first.
|
||||
"""
|
||||
def list_snapshots(device_id, limit \\ 20) do
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.device_id == ^device_id,
|
||||
order_by: [desc: s.snapshot_at],
|
||||
limit: ^limit,
|
||||
select: %{
|
||||
id: s.id,
|
||||
config_hash: s.config_hash,
|
||||
config_size_bytes: s.config_size_bytes,
|
||||
compressed_size_bytes: s.compressed_size_bytes,
|
||||
snapshot_at: s.snapshot_at
|
||||
}
|
||||
Repo.all(
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.device_id == ^device_id,
|
||||
order_by: [desc: s.snapshot_at],
|
||||
limit: ^limit,
|
||||
select: %{
|
||||
id: s.id,
|
||||
config_hash: s.config_hash,
|
||||
config_size_bytes: s.config_size_bytes,
|
||||
compressed_size_bytes: s.compressed_size_bytes,
|
||||
snapshot_at: s.snapshot_at
|
||||
}
|
||||
)
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -121,7 +123,7 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
{:error, :not_found}
|
||||
|
||||
compressed ->
|
||||
{:ok, :zlib.uncompress(compressed) |> Jason.decode!()}
|
||||
{:ok, compressed |> :zlib.uncompress() |> Jason.decode!()}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -158,7 +160,8 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
defp compute_diff(_a, _b), do: %{added: [], removed: [], changed: []}
|
||||
|
||||
defp insert_snapshot(attrs) do
|
||||
Repo.insert_all("uisp_config_snapshots", [
|
||||
"uisp_config_snapshots"
|
||||
|> Repo.insert_all([
|
||||
Map.merge(attrs, %{
|
||||
id: Ecto.UUID.generate(),
|
||||
inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ defmodule Towerops.Uisp.DeviceSync do
|
|||
alias Towerops.Devices.Device
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Uisp.Client
|
||||
|
||||
require Logger
|
||||
|
|
@ -44,36 +44,51 @@ defmodule Towerops.Uisp.DeviceSync do
|
|||
|
||||
defp process_devices(org_id, devices, site_map) do
|
||||
Enum.reduce(devices, %{matched: 0, created: 0}, fn device_data, acc ->
|
||||
ip = device_data["ipAddress"]
|
||||
mac = get_in(device_data, ["identification", "mac"])
|
||||
name = get_in(device_data, ["identification", "name"])
|
||||
uisp_site_id = get_in(device_data, ["site", "id"])
|
||||
local_site = site_map[uisp_site_id]
|
||||
|
||||
if is_nil(ip) or ip == "" do
|
||||
acc
|
||||
else
|
||||
case find_existing_device(org_id, ip, mac) do
|
||||
nil ->
|
||||
case create_device(org_id, ip, name, local_site, device_data) do
|
||||
{:ok, _device} -> Map.update!(acc, :created, &(&1 + 1))
|
||||
{:error, reason} ->
|
||||
Logger.warning("UISP device sync: failed to create device #{ip}: #{inspect(reason)}")
|
||||
acc
|
||||
end
|
||||
|
||||
device ->
|
||||
case update_device_site(device, local_site) do
|
||||
{:ok, _device} -> Map.update!(acc, :matched, &(&1 + 1))
|
||||
{:error, reason} ->
|
||||
Logger.warning("UISP device sync: failed to update device #{ip}: #{inspect(reason)}")
|
||||
acc
|
||||
end
|
||||
end
|
||||
end
|
||||
process_single_device(org_id, device_data, site_map, acc)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_single_device(org_id, device_data, site_map, acc) do
|
||||
ip = device_data["ipAddress"]
|
||||
mac = get_in(device_data, ["identification", "mac"])
|
||||
name = get_in(device_data, ["identification", "name"])
|
||||
uisp_site_id = get_in(device_data, ["site", "id"])
|
||||
local_site = site_map[uisp_site_id]
|
||||
|
||||
cond do
|
||||
is_nil(ip) or ip == "" ->
|
||||
acc
|
||||
|
||||
existing = find_existing_device(org_id, ip, mac) ->
|
||||
handle_existing_device(existing, local_site, ip, acc)
|
||||
|
||||
true ->
|
||||
handle_new_device(org_id, ip, name, local_site, device_data, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_existing_device(device, local_site, ip, acc) do
|
||||
case update_device_site(device, local_site) do
|
||||
{:ok, _device} ->
|
||||
Map.update!(acc, :matched, &(&1 + 1))
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("UISP device sync: failed to update device #{ip}: #{inspect(reason)}")
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_new_device(org_id, ip, name, local_site, device_data, acc) do
|
||||
case create_device(org_id, ip, name, local_site, device_data) do
|
||||
{:ok, _device} ->
|
||||
Map.update!(acc, :created, &(&1 + 1))
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("UISP device sync: failed to create device #{ip}: #{inspect(reason)}")
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
defp find_existing_device(org_id, ip, mac) do
|
||||
find_by_ip(org_id, ip) || find_by_mac(org_id, mac)
|
||||
end
|
||||
|
|
@ -105,7 +120,7 @@ defmodule Towerops.Uisp.DeviceSync do
|
|||
end
|
||||
|
||||
defp create_device(org_id, ip, name, site, raw_data) do
|
||||
site_id = if site, do: site.id, else: nil
|
||||
site_id = if site, do: site.id
|
||||
|
||||
attrs = %{
|
||||
organization_id: org_id,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ defmodule Towerops.Uisp.GpsSync do
|
|||
and its associated site with coordinates for map enrichment.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites.Site
|
||||
|
|
@ -43,7 +41,8 @@ defmodule Towerops.Uisp.GpsSync do
|
|||
metadata = device.metadata || %{}
|
||||
|
||||
if is_nil(metadata["latitude"]) or is_nil(metadata["longitude"]) do
|
||||
new_metadata = Map.merge(metadata, %{"latitude" => to_float(lat), "longitude" => to_float(lon), "gps_source" => "uisp"})
|
||||
new_metadata =
|
||||
Map.merge(metadata, %{"latitude" => to_float(lat), "longitude" => to_float(lon), "gps_source" => "uisp"})
|
||||
|
||||
case device
|
||||
|> Device.changeset(%{metadata: new_metadata})
|
||||
|
|
@ -57,21 +56,16 @@ defmodule Towerops.Uisp.GpsSync do
|
|||
end
|
||||
|
||||
defp maybe_update_site_gps(acc, device, lat, lon) do
|
||||
if device.site_id do
|
||||
site = Repo.get(Site, device.site_id)
|
||||
|
||||
if site && (is_nil(site.latitude) or is_nil(site.longitude)) do
|
||||
case site
|
||||
|> Site.changeset(%{latitude: to_float(lat), longitude: to_float(lon)})
|
||||
|> Repo.update() do
|
||||
{:ok, _} -> Map.update!(acc, :sites_updated, &(&1 + 1))
|
||||
{:error, _} -> acc
|
||||
end
|
||||
else
|
||||
acc
|
||||
end
|
||||
with site_id when not is_nil(site_id) <- device.site_id,
|
||||
%Site{} = site <- Repo.get(Site, site_id),
|
||||
true <- is_nil(site.latitude) or is_nil(site.longitude),
|
||||
{:ok, _} <-
|
||||
site
|
||||
|> Site.changeset(%{latitude: to_float(lat), longitude: to_float(lon)})
|
||||
|> Repo.update() do
|
||||
Map.update!(acc, :sites_updated, &(&1 + 1))
|
||||
else
|
||||
acc
|
||||
_ -> acc
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -38,34 +38,42 @@ defmodule Towerops.Uisp.SiteSync do
|
|||
|
||||
defp upsert_sites(org_id, sites) do
|
||||
Enum.reduce(sites, {0, %{}}, fn site_data, {count, acc} ->
|
||||
uisp_id = get_in(site_data, ["identification", "id"]) || site_data["id"]
|
||||
name = get_in(site_data, ["identification", "name"]) || site_data["name"]
|
||||
latitude = get_in(site_data, ["description", "gps", "latitude"])
|
||||
longitude = get_in(site_data, ["description", "gps", "longitude"])
|
||||
|
||||
if is_nil(name) or name == "" do
|
||||
{count, acc}
|
||||
else
|
||||
attrs = %{
|
||||
organization_id: org_id,
|
||||
name: name,
|
||||
latitude: to_float(latitude),
|
||||
longitude: to_float(longitude)
|
||||
}
|
||||
|
||||
case upsert_site(attrs) do
|
||||
{:ok, site} ->
|
||||
new_acc = if uisp_id, do: Map.put(acc, uisp_id, site), else: acc
|
||||
{count + 1, new_acc}
|
||||
|
||||
{:error, changeset} ->
|
||||
Logger.warning("UISP site sync: failed to upsert site #{name}: #{inspect(changeset.errors)}")
|
||||
{count, acc}
|
||||
end
|
||||
end
|
||||
process_site(org_id, site_data, count, acc)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_site(org_id, site_data, count, acc) do
|
||||
uisp_id = get_in(site_data, ["identification", "id"]) || site_data["id"]
|
||||
name = get_in(site_data, ["identification", "name"]) || site_data["name"]
|
||||
latitude = get_in(site_data, ["description", "gps", "latitude"])
|
||||
longitude = get_in(site_data, ["description", "gps", "longitude"])
|
||||
|
||||
if is_nil(name) or name == "" do
|
||||
{count, acc}
|
||||
else
|
||||
attrs = %{
|
||||
organization_id: org_id,
|
||||
name: name,
|
||||
latitude: to_float(latitude),
|
||||
longitude: to_float(longitude)
|
||||
}
|
||||
|
||||
handle_site_upsert(attrs, uisp_id, name, count, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_site_upsert(attrs, uisp_id, name, count, acc) do
|
||||
case upsert_site(attrs) do
|
||||
{:ok, site} ->
|
||||
new_acc = if uisp_id, do: Map.put(acc, uisp_id, site), else: acc
|
||||
{count + 1, new_acc}
|
||||
|
||||
{:error, changeset} ->
|
||||
Logger.warning("UISP site sync: failed to upsert site #{name}: #{inspect(changeset.errors)}")
|
||||
{count, acc}
|
||||
end
|
||||
end
|
||||
|
||||
defp upsert_site(attrs) do
|
||||
%Site{}
|
||||
|> Site.changeset(attrs)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.Uisp.Client
|
||||
|
||||
|
|
@ -50,22 +51,31 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
|
||||
defp process_device_stats(local_device, stats, acc) when is_list(stats) do
|
||||
Enum.reduce(stats, acc, fn stat, inner_acc ->
|
||||
interface_name = stat["interfaceName"] || stat["name"] || "unknown"
|
||||
checked_at = parse_timestamp(stat["timestamp"])
|
||||
|
||||
if checked_at && !recently_polled?(local_device.id, interface_name, checked_at) do
|
||||
case insert_stat(local_device, interface_name, stat, checked_at) do
|
||||
{:ok, _} -> Map.update!(inner_acc, :inserted, &(&1 + 1))
|
||||
{:error, _} -> inner_acc
|
||||
end
|
||||
else
|
||||
Map.update!(inner_acc, :skipped_dedup, &(&1 + 1))
|
||||
end
|
||||
process_single_stat(local_device, stat, inner_acc)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_device_stats(_device, _stats, acc), do: acc
|
||||
|
||||
defp process_single_stat(local_device, stat, acc) do
|
||||
interface_name = stat["interfaceName"] || stat["name"] || "unknown"
|
||||
checked_at = parse_timestamp(stat["timestamp"])
|
||||
|
||||
cond do
|
||||
is_nil(checked_at) ->
|
||||
Map.update!(acc, :skipped_dedup, &(&1 + 1))
|
||||
|
||||
recently_polled?(local_device.id, interface_name, checked_at) ->
|
||||
Map.update!(acc, :skipped_dedup, &(&1 + 1))
|
||||
|
||||
true ->
|
||||
case insert_stat(local_device, interface_name, stat, checked_at) do
|
||||
{:ok, _} -> Map.update!(acc, :inserted, &(&1 + 1))
|
||||
{:error, _} -> acc
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if SNMP already polled this interface recently, to avoid double-counting.
|
||||
|
||||
|
|
@ -75,7 +85,7 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
cutoff = DateTime.add(checked_at, -@dedup_window_seconds, :second)
|
||||
|
||||
InterfaceStat
|
||||
|> join(:inner, [s], i in Towerops.Snmp.Interface, on: s.interface_id == i.id)
|
||||
|> join(:inner, [s], i in Interface, on: s.interface_id == i.id)
|
||||
|> where([_s, i], i.snmp_device_id == ^snmp_device_id)
|
||||
|> where([s, _i], s.checked_at >= ^cutoff and s.checked_at <= ^checked_at)
|
||||
|> limit(1)
|
||||
|
|
@ -91,9 +101,9 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
%InterfaceStat{}
|
||||
|> InterfaceStat.changeset(%{
|
||||
interface_id: interface.id,
|
||||
if_in_octets: stat["receive"] |> parse_counter(),
|
||||
if_out_octets: stat["transmit"] |> parse_counter(),
|
||||
if_in_errors: stat["errors"] |> parse_counter(),
|
||||
if_in_octets: parse_counter(stat["receive"]),
|
||||
if_out_octets: parse_counter(stat["transmit"]),
|
||||
if_in_errors: parse_counter(stat["errors"]),
|
||||
if_out_errors: 0,
|
||||
if_in_discards: 0,
|
||||
if_out_discards: 0,
|
||||
|
|
@ -106,7 +116,7 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
end
|
||||
|
||||
defp find_primary_interface(snmp_device_id) do
|
||||
Towerops.Snmp.Interface
|
||||
Interface
|
||||
|> where(snmp_device_id: ^snmp_device_id)
|
||||
|> order_by(asc: :if_index)
|
||||
|> limit(1)
|
||||
|
|
@ -136,7 +146,7 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
end
|
||||
|
||||
defp parse_timestamp(ts) when is_integer(ts) do
|
||||
DateTime.from_unix(ts) |> elem(1) |> DateTime.truncate(:second)
|
||||
ts |> DateTime.from_unix() |> elem(1) |> DateTime.truncate(:second)
|
||||
end
|
||||
|
||||
defp parse_timestamp(_), do: nil
|
||||
|
|
|
|||
|
|
@ -33,8 +33,12 @@ defmodule Towerops.Uisp.Sync do
|
|||
# Phase 2: GPS enrichment, statistics sync, config snapshots
|
||||
# These are best-effort — failures don't block the main sync
|
||||
gps_result = safe_sync(:gps, fn -> GpsSync.sync_gps(device_result.device_pairs || []) end)
|
||||
stats_result = safe_sync(:stats, fn -> StatisticsSync.sync_statistics(integration, device_result.device_map || %{}) end)
|
||||
config_result = safe_sync(:config, fn -> ConfigSnapshot.sync_configs(integration, device_result.device_map || %{}) end)
|
||||
|
||||
stats_result =
|
||||
safe_sync(:stats, fn -> StatisticsSync.sync_statistics(integration, device_result.device_map || %{}) end)
|
||||
|
||||
config_result =
|
||||
safe_sync(:config, fn -> ConfigSnapshot.sync_configs(integration, device_result.device_map || %{}) end)
|
||||
|
||||
duration_ms = System.monotonic_time(:millisecond) - start_time
|
||||
total_synced = site_result.synced + device_result.matched + device_result.created
|
||||
|
|
@ -42,11 +46,18 @@ defmodule Towerops.Uisp.Sync do
|
|||
message =
|
||||
"Synced #{site_result.synced} sites, matched #{device_result.matched} devices, created #{device_result.created} devices"
|
||||
|
||||
log_sync(integration, "success", total_synced, %{
|
||||
gps: inspect(gps_result),
|
||||
stats: inspect(stats_result),
|
||||
config: inspect(config_result)
|
||||
}, duration_ms)
|
||||
log_sync(
|
||||
integration,
|
||||
"success",
|
||||
total_synced,
|
||||
%{
|
||||
gps: inspect(gps_result),
|
||||
stats: inspect(stats_result),
|
||||
config: inspect(config_result)
|
||||
},
|
||||
duration_ms
|
||||
)
|
||||
|
||||
Integrations.update_sync_status(integration, "success", message)
|
||||
|
||||
{:ok,
|
||||
|
|
@ -88,7 +99,9 @@ defmodule Towerops.Uisp.Sync do
|
|||
|
||||
defp safe_sync(label, fun) do
|
||||
case fun.() do
|
||||
{:ok, result} -> result
|
||||
{:ok, result} ->
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("UISP #{label} sync failed: #{inspect(reason)}")
|
||||
%{error: reason}
|
||||
|
|
|
|||
|
|
@ -48,16 +48,14 @@ defmodule Towerops.Workers.AlertDigestWorker do
|
|||
digest ->
|
||||
alert_ids = digest.suppressed_alert_ids || []
|
||||
|
||||
if length(alert_ids) > 0 do
|
||||
alerts =
|
||||
alert_ids
|
||||
|> Enum.map(&Alerts.get_alert/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
if length(alerts) > 0 do
|
||||
send_digest_notification(alerts)
|
||||
NotificationRateLimiter.mark_digest_sent(digest)
|
||||
end
|
||||
with [_ | _] <- alert_ids,
|
||||
alerts =
|
||||
alert_ids
|
||||
|> Enum.map(&Alerts.get_alert/1)
|
||||
|> Enum.reject(&is_nil/1),
|
||||
[_ | _] <- alerts do
|
||||
send_digest_notification(alerts)
|
||||
NotificationRateLimiter.mark_digest_sent(digest)
|
||||
end
|
||||
|
||||
:ok
|
||||
|
|
|
|||
|
|
@ -23,34 +23,7 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
def perform(%Oban.Job{args: %{"action" => "trigger", "alert_id" => alert_id}}) do
|
||||
with {:ok, alert} <- fetch_alert(alert_id),
|
||||
{:ok, device} <- fetch_device(alert.device_id) do
|
||||
# Skip notification entirely if alert was storm-suppressed
|
||||
if alert.storm_suppressed do
|
||||
Logger.debug("Skipping notification for storm-suppressed alert #{alert_id}")
|
||||
:ok
|
||||
else
|
||||
routing = alert_routing(device)
|
||||
|
||||
# Apply per-user rate limiting for built-in notifications
|
||||
rate_limited_users =
|
||||
if routing in ["builtin", "both"] do
|
||||
check_and_apply_rate_limits(alert, device)
|
||||
else
|
||||
MapSet.new()
|
||||
end
|
||||
|
||||
pd_result =
|
||||
if routing in ["pagerduty", "both"] do
|
||||
notify_pagerduty_trigger(alert, device, alert_id)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
if routing in ["builtin", "both"] do
|
||||
maybe_start_escalation(alert, device, rate_limited_users)
|
||||
end
|
||||
|
||||
pd_result
|
||||
end
|
||||
handle_trigger_notification(alert, device, alert_id)
|
||||
else
|
||||
{:error, :alert_not_found} ->
|
||||
Logger.warning("Alert #{alert_id} not found for notification")
|
||||
|
|
@ -131,6 +104,40 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp handle_trigger_notification(alert, device, alert_id) do
|
||||
if alert.storm_suppressed do
|
||||
Logger.debug("Skipping notification for storm-suppressed alert #{alert_id}")
|
||||
:ok
|
||||
else
|
||||
send_notifications(alert, device, alert_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp send_notifications(alert, device, alert_id) do
|
||||
routing = alert_routing(device)
|
||||
|
||||
# Apply per-user rate limiting for built-in notifications
|
||||
rate_limited_users =
|
||||
if routing in ["builtin", "both"] do
|
||||
check_and_apply_rate_limits(alert, device)
|
||||
else
|
||||
MapSet.new()
|
||||
end
|
||||
|
||||
pd_result =
|
||||
if routing in ["pagerduty", "both"] do
|
||||
notify_pagerduty_trigger(alert, device, alert_id)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
if routing in ["builtin", "both"] do
|
||||
maybe_start_escalation(alert, device, rate_limited_users)
|
||||
end
|
||||
|
||||
pd_result
|
||||
end
|
||||
|
||||
defp notify_pagerduty_trigger(alert, device, alert_id) do
|
||||
case Notifier.notify_trigger(alert, device) do
|
||||
{:error, :not_configured} ->
|
||||
|
|
@ -184,24 +191,23 @@ defmodule Towerops.Workers.AlertNotificationWorker do
|
|||
org_id = device.organization_id
|
||||
policy_id = device.escalation_policy_id || device.organization.default_escalation_policy_id
|
||||
|
||||
if policy_id do
|
||||
# Get on-call users from escalation policy's first rule targets
|
||||
user_ids = resolve_escalation_targets(policy_id)
|
||||
policy_id
|
||||
|> case do
|
||||
nil -> []
|
||||
id -> resolve_escalation_targets(id)
|
||||
end
|
||||
|> Enum.filter(&should_suppress_for_user?(&1, org_id, alert.id))
|
||||
|> MapSet.new()
|
||||
end
|
||||
|
||||
user_ids
|
||||
|> Enum.filter(fn user_id ->
|
||||
case NotificationRateLimiter.check_rate(user_id, org_id, alert.id) do
|
||||
:suppress ->
|
||||
AlertDigestWorker.enqueue(user_id)
|
||||
true
|
||||
defp should_suppress_for_user?(user_id, org_id, alert_id) do
|
||||
case NotificationRateLimiter.check_rate(user_id, org_id, alert_id) do
|
||||
:suppress ->
|
||||
AlertDigestWorker.enqueue(user_id)
|
||||
true
|
||||
|
||||
:allow ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
|> MapSet.new()
|
||||
else
|
||||
MapSet.new()
|
||||
:allow ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -339,378 +339,383 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
id="app-sidebar"
|
||||
class="fixed top-14 left-0 bottom-0 z-30 hidden lg:flex lg:flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-white/10 overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<!-- Nav sections -->
|
||||
<nav class="flex-1 px-2 py-4 space-y-1">
|
||||
<!-- MONITOR section -->
|
||||
<p
|
||||
data-sidebar-section
|
||||
class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{t("Monitor")}
|
||||
</p>
|
||||
<.sidebar_link
|
||||
navigate={~p"/dashboard"}
|
||||
active={@active_page == "dashboard"}
|
||||
icon="hero-home"
|
||||
label={t("Dashboard")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/devices"}
|
||||
active={@active_page == "devices"}
|
||||
icon="hero-server"
|
||||
label={t("Devices")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
:if={@current_organization && @current_organization.use_sites}
|
||||
navigate={~p"/sites"}
|
||||
active={@active_page == "sites"}
|
||||
icon="hero-building-office"
|
||||
label={t("Sites")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/network-map"}
|
||||
active={@active_page == "network-map"}
|
||||
icon="hero-map"
|
||||
label={t("Network Map")}
|
||||
/>
|
||||
|
||||
<!-- RESPOND section -->
|
||||
<p
|
||||
data-sidebar-section
|
||||
class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{t("Respond")}
|
||||
</p>
|
||||
<.sidebar_link
|
||||
navigate={~p"/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
icon="hero-bell-alert"
|
||||
label={t("Alerts")}
|
||||
badge={@unresolved_alert_count}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/maintenance"}
|
||||
active={@active_page == "maintenance"}
|
||||
icon="hero-wrench-screwdriver"
|
||||
label={t("Maintenance")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/schedules"}
|
||||
active={@active_page == "schedules"}
|
||||
icon="hero-calendar-days"
|
||||
label={t("On-Call")}
|
||||
/>
|
||||
|
||||
<!-- ANALYZE section -->
|
||||
<p
|
||||
data-sidebar-section
|
||||
class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{t("Analyze")}
|
||||
</p>
|
||||
<.sidebar_link
|
||||
navigate={~p"/rf-links"}
|
||||
active={@active_page == "rf-links"}
|
||||
icon="hero-signal"
|
||||
label={t("RF Links")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/insights"}
|
||||
active={@active_page == "insights"}
|
||||
icon="hero-light-bulb"
|
||||
label={t("Insights")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/trace"}
|
||||
active={@active_page == "trace"}
|
||||
icon="hero-magnifying-glass"
|
||||
label={t("Trace")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/activity"}
|
||||
active={@active_page == "activity"}
|
||||
icon="hero-clock"
|
||||
label={t("Activity")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/reports"}
|
||||
active={@active_page == "reports"}
|
||||
icon="hero-document-chart-bar"
|
||||
label={t("Reports")}
|
||||
/>
|
||||
</nav>
|
||||
<!-- Sidebar + Main content wrapper -->
|
||||
<div class="flex">
|
||||
|
||||
<!-- Bottom pinned: org switcher + settings + collapse toggle -->
|
||||
<div class="border-t border-gray-200 dark:border-white/10 px-2 py-3.5 space-y-1">
|
||||
<!-- Org switcher -->
|
||||
<div :if={@current_organization} class="relative">
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-org-switcher-button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
phx-click={
|
||||
JS.toggle(to: "#sidebar-org-switcher-menu")
|
||||
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
||||
to: "#sidebar-org-switcher-button"
|
||||
)
|
||||
}
|
||||
class="flex items-center gap-2 w-full rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white transition-colors"
|
||||
data-sidebar-tooltip={@current_organization.name}
|
||||
>
|
||||
<.icon name="hero-building-office-2" class="h-5 w-5 shrink-0" />
|
||||
<span data-sidebar-text class="truncate flex-1 text-left">
|
||||
{@current_organization.name}
|
||||
</span>
|
||||
<span data-sidebar-text>
|
||||
<.icon name="hero-chevron-up-down" class="h-3 w-3 shrink-0" />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id="sidebar-org-switcher-menu"
|
||||
role="menu"
|
||||
class="hidden absolute bottom-full left-0 mb-1 w-48 origin-bottom-left rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
|
||||
phx-click-away={
|
||||
JS.hide(to: "#sidebar-org-switcher-menu")
|
||||
|> JS.set_attribute({"aria-expanded", "false"}, to: "#sidebar-org-switcher-button")
|
||||
}
|
||||
>
|
||||
<div class="py-1">
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/orgs"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
|
||||
>
|
||||
{t("Switch Organization")}
|
||||
</.link>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
|
||||
>
|
||||
{t("Organization Settings")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings link -->
|
||||
<.sidebar_link
|
||||
:if={@current_organization}
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
||||
active={@active_page == "settings"}
|
||||
icon="hero-cog-6-tooth"
|
||||
label={t("Settings")}
|
||||
/>
|
||||
|
||||
<!-- Collapse toggle -->
|
||||
<button
|
||||
type="button"
|
||||
data-sidebar-toggle
|
||||
class="flex items-center justify-center w-full rounded-md px-3 py-2 text-gray-400 hover:bg-gray-50 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-colors"
|
||||
title={t("Toggle sidebar")}
|
||||
>
|
||||
<.icon name="hero-chevron-double-left" class="h-5 w-5 sidebar-collapse-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile slide-out panel -->
|
||||
<div
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
id="mobile-menu"
|
||||
class="hidden fixed inset-0 z-50 lg:hidden"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 dark:bg-black/50"
|
||||
phx-click={JS.hide(to: "#mobile-menu")}
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
id="app-sidebar"
|
||||
class="sticky top-14 left-0 z-30 hidden lg:flex lg:flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-white/10 overflow-y-auto overflow-x-hidden h-[calc(100vh-3.5rem)]"
|
||||
>
|
||||
</div>
|
||||
<!-- Panel -->
|
||||
<div class="fixed inset-y-0 left-0 w-72 bg-white dark:bg-gray-900 shadow-xl overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-white/10">
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white">Towerops</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click={JS.hide(to: "#mobile-menu")}
|
||||
class="p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
<!-- Nav sections -->
|
||||
<nav class="flex-1 px-2 py-4 space-y-1">
|
||||
<!-- MONITOR section -->
|
||||
<p
|
||||
data-sidebar-section
|
||||
class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Org name -->
|
||||
<div
|
||||
:if={@current_organization}
|
||||
class="px-4 py-3 border-b border-gray-100 dark:border-white/5"
|
||||
>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t("Organization")}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-0.5">
|
||||
{@current_organization.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MONITOR -->
|
||||
<div class="px-2 py-3">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Monitor")}
|
||||
</p>
|
||||
<.mobile_nav_link navigate={~p"/dashboard"} active={@active_page == "dashboard"}>
|
||||
<.icon name="hero-home" class="size-5" /> {t("Dashboard")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/devices"} active={@active_page == "devices"}>
|
||||
<.icon name="hero-server" class="size-5" /> {t("Devices")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
<.sidebar_link
|
||||
navigate={~p"/dashboard"}
|
||||
active={@active_page == "dashboard"}
|
||||
icon="hero-home"
|
||||
label={t("Dashboard")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/devices"}
|
||||
active={@active_page == "devices"}
|
||||
icon="hero-server"
|
||||
label={t("Devices")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
:if={@current_organization && @current_organization.use_sites}
|
||||
navigate={~p"/sites"}
|
||||
active={@active_page == "sites"}
|
||||
icon="hero-building-office"
|
||||
label={t("Sites")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/network-map"}
|
||||
active={@active_page == "network-map"}
|
||||
icon="hero-map"
|
||||
label={t("Network Map")}
|
||||
/>
|
||||
|
||||
<!-- RESPOND section -->
|
||||
<p
|
||||
data-sidebar-section
|
||||
class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<.icon name="hero-building-office" class="size-5" /> {t("Sites")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
|
||||
<.icon name="hero-map" class="size-5" /> {t("Network Map")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- RESPOND -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Respond")}
|
||||
</p>
|
||||
<.mobile_nav_link navigate={~p"/alerts"} active={@active_page == "alerts"}>
|
||||
<.icon name="hero-bell-alert" class="size-5" /> {t("Alerts")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
|
||||
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
|
||||
<.icon name="hero-calendar-days" class="size-5" /> {t("On-Call")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- ANALYZE -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
<.sidebar_link
|
||||
navigate={~p"/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
icon="hero-bell-alert"
|
||||
label={t("Alerts")}
|
||||
badge={@unresolved_alert_count}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/maintenance"}
|
||||
active={@active_page == "maintenance"}
|
||||
icon="hero-wrench-screwdriver"
|
||||
label={t("Maintenance")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/schedules"}
|
||||
active={@active_page == "schedules"}
|
||||
icon="hero-calendar-days"
|
||||
label={t("On-Call")}
|
||||
/>
|
||||
|
||||
<!-- ANALYZE section -->
|
||||
<p
|
||||
data-sidebar-section
|
||||
class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{t("Analyze")}
|
||||
</p>
|
||||
<.mobile_nav_link navigate={~p"/insights"} active={@active_page == "insights"}>
|
||||
<.icon name="hero-light-bulb" class="size-5" /> {t("Insights")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/trace"} active={@active_page == "trace"}>
|
||||
<.icon name="hero-magnifying-glass" class="size-5" /> {t("Trace")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
|
||||
<.icon name="hero-clock" class="size-5" /> {t("Activity")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
<.sidebar_link
|
||||
navigate={~p"/rf-links"}
|
||||
active={@active_page == "rf-links"}
|
||||
icon="hero-signal"
|
||||
label={t("RF Links")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/insights"}
|
||||
active={@active_page == "insights"}
|
||||
icon="hero-light-bulb"
|
||||
label={t("Insights")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/trace"}
|
||||
active={@active_page == "trace"}
|
||||
icon="hero-magnifying-glass"
|
||||
label={t("Trace")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/activity"}
|
||||
active={@active_page == "activity"}
|
||||
icon="hero-clock"
|
||||
label={t("Activity")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/reports"}
|
||||
active={@active_page == "reports"}
|
||||
icon="hero-document-chart-bar"
|
||||
label={t("Reports")}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Account")}
|
||||
</p>
|
||||
<.mobile_nav_link
|
||||
<!-- Bottom pinned: org switcher + settings + collapse toggle -->
|
||||
<div class="border-t border-gray-200 dark:border-white/10 px-2 py-3.5 space-y-1">
|
||||
<!-- Org switcher -->
|
||||
<div :if={@current_organization} class="relative">
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-org-switcher-button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
phx-click={
|
||||
JS.toggle(to: "#sidebar-org-switcher-menu")
|
||||
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
||||
to: "#sidebar-org-switcher-button"
|
||||
)
|
||||
}
|
||||
class="flex items-center gap-2 w-full rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white transition-colors"
|
||||
data-sidebar-tooltip={@current_organization.name}
|
||||
>
|
||||
<.icon name="hero-building-office-2" class="h-5 w-5 shrink-0" />
|
||||
<span data-sidebar-text class="truncate flex-1 text-left">
|
||||
{@current_organization.name}
|
||||
</span>
|
||||
<span data-sidebar-text>
|
||||
<.icon name="hero-chevron-up-down" class="h-3 w-3 shrink-0" />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id="sidebar-org-switcher-menu"
|
||||
role="menu"
|
||||
class="hidden absolute bottom-full left-0 mb-1 w-48 origin-bottom-left rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
|
||||
phx-click-away={
|
||||
JS.hide(to: "#sidebar-org-switcher-menu")
|
||||
|> JS.set_attribute({"aria-expanded", "false"}, to: "#sidebar-org-switcher-button")
|
||||
}
|
||||
>
|
||||
<div class="py-1">
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/orgs"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
|
||||
>
|
||||
{t("Switch Organization")}
|
||||
</.link>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
|
||||
>
|
||||
{t("Organization Settings")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings link -->
|
||||
<.sidebar_link
|
||||
:if={@current_organization}
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
||||
active={@active_page == "settings"}
|
||||
icon="hero-cog-6-tooth"
|
||||
label={t("Settings")}
|
||||
/>
|
||||
|
||||
<!-- Collapse toggle -->
|
||||
<button
|
||||
type="button"
|
||||
data-sidebar-toggle
|
||||
class="flex items-center justify-center w-full rounded-md px-3 py-2 text-gray-400 hover:bg-gray-50 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-colors"
|
||||
title={t("Toggle sidebar")}
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" /> {t("Organization Settings")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/users/settings"}
|
||||
active={@active_page == "user_settings"}
|
||||
<.icon name="hero-chevron-double-left" class="h-5 w-5 sidebar-collapse-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile slide-out panel -->
|
||||
<div
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
id="mobile-menu"
|
||||
class="hidden fixed inset-0 z-50 lg:hidden"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 dark:bg-black/50"
|
||||
phx-click={JS.hide(to: "#mobile-menu")}
|
||||
>
|
||||
</div>
|
||||
<!-- Panel -->
|
||||
<div class="fixed inset-y-0 left-0 w-72 bg-white dark:bg-gray-900 shadow-xl overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-white/10">
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white">Towerops</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click={JS.hide(to: "#mobile-menu")}
|
||||
class="p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Org name -->
|
||||
<div
|
||||
:if={@current_organization}
|
||||
class="px-4 py-3 border-b border-gray-100 dark:border-white/5"
|
||||
>
|
||||
<.icon name="hero-user-circle" class="size-5" /> {t("My Settings")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/help"} active={@active_page == "help"}>
|
||||
<.icon name="hero-question-mark-circle" class="size-5" /> {t("Help")}
|
||||
</.mobile_nav_link>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t("Organization")}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-0.5">
|
||||
{@current_organization.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MONITOR -->
|
||||
<div class="px-2 py-3">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Monitor")}
|
||||
</p>
|
||||
<.mobile_nav_link navigate={~p"/dashboard"} active={@active_page == "dashboard"}>
|
||||
<.icon name="hero-home" class="size-5" /> {t("Dashboard")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/devices"} active={@active_page == "devices"}>
|
||||
<.icon name="hero-server" class="size-5" /> {t("Devices")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
:if={@current_organization && @current_organization.use_sites}
|
||||
navigate={~p"/sites"}
|
||||
active={@active_page == "sites"}
|
||||
>
|
||||
<.icon name="hero-building-office" class="size-5" /> {t("Sites")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
|
||||
<.icon name="hero-map" class="size-5" /> {t("Network Map")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- RESPOND -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Respond")}
|
||||
</p>
|
||||
<.mobile_nav_link navigate={~p"/alerts"} active={@active_page == "alerts"}>
|
||||
<.icon name="hero-bell-alert" class="size-5" /> {t("Alerts")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
|
||||
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
|
||||
<.icon name="hero-calendar-days" class="size-5" /> {t("On-Call")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- ANALYZE -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Analyze")}
|
||||
</p>
|
||||
<.mobile_nav_link navigate={~p"/insights"} active={@active_page == "insights"}>
|
||||
<.icon name="hero-light-bulb" class="size-5" /> {t("Insights")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/trace"} active={@active_page == "trace"}>
|
||||
<.icon name="hero-magnifying-glass" class="size-5" /> {t("Trace")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
|
||||
<.icon name="hero-clock" class="size-5" /> {t("Activity")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{t("Account")}
|
||||
</p>
|
||||
<.mobile_nav_link
|
||||
:if={@current_organization}
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
||||
active={@active_page == "settings"}
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" /> {t("Organization Settings")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/users/settings"}
|
||||
active={@active_page == "user_settings"}
|
||||
>
|
||||
<.icon name="hero-user-circle" class="size-5" /> {t("My Settings")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/help"} active={@active_page == "help"}>
|
||||
<.icon name="hero-question-mark-circle" class="size-5" /> {t("Help")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Main content -->
|
||||
<div id="main-wrapper" class="flex flex-col min-h-[calc(100vh-3.5rem)]">
|
||||
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10 page-fade-in">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<!-- Beta Testing Banner -->
|
||||
<div
|
||||
id="beta-banner"
|
||||
phx-hook="BetaBannerDismiss"
|
||||
data-always-show={@always_show_banner}
|
||||
class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<.icon
|
||||
name="hero-heart"
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
Thanks for testing Towerops!
|
||||
</h3>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
We'd love to hear your feedback, suggestions, or help you with any questions. Please email us at
|
||||
<a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="font-medium underline hover:text-blue-900 dark:hover:text-blue-200"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>
|
||||
for support, feature requests, or just to say hello!
|
||||
</p>
|
||||
<div id="main-wrapper" class="flex flex-1 flex-col min-h-[calc(100vh-3.5rem)]">
|
||||
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10 page-fade-in">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<!-- Beta Testing Banner -->
|
||||
<div
|
||||
id="beta-banner"
|
||||
phx-hook="BetaBannerDismiss"
|
||||
data-always-show={@always_show_banner}
|
||||
class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<.icon
|
||||
name="hero-heart"
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
Thanks for testing Towerops!
|
||||
</h3>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
We'd love to hear your feedback, suggestions, or help you with any questions. Please email us at
|
||||
<a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="font-medium underline hover:text-blue-900 dark:hover:text-blue-200"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>
|
||||
for support, feature requests, or just to say hello!
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-dismiss
|
||||
class="flex-shrink-0 rounded-md p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-800/50 focus:outline-none focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-400"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-dismiss
|
||||
class="flex-shrink-0 rounded-md p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-800/50 focus:outline-none focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-400"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var banner = document.getElementById('beta-banner');
|
||||
if (banner) {
|
||||
var isHelpPage = banner.dataset.alwaysShow === 'true';
|
||||
<script>
|
||||
(function() {
|
||||
var banner = document.getElementById('beta-banner');
|
||||
if (banner) {
|
||||
var isHelpPage = banner.dataset.alwaysShow === 'true';
|
||||
|
||||
if (isHelpPage) {
|
||||
// On help page: keep banner visible, hide dismiss button
|
||||
var dismissBtn = banner.querySelector('[data-dismiss]');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// On other pages: check if dismissed
|
||||
var isDismissed = localStorage.getItem('betaBannerDismissed') === 'true';
|
||||
if (isDismissed) {
|
||||
banner.style.display = 'none';
|
||||
if (isHelpPage) {
|
||||
// On help page: keep banner visible, hide dismiss button
|
||||
var dismissBtn = banner.querySelector('[data-dismiss]');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// On other pages: check if dismissed
|
||||
var isDismissed = localStorage.getItem('betaBannerDismissed') === 'true';
|
||||
if (isDismissed) {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
})();
|
||||
</script>
|
||||
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
|
||||
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
|
||||
</div>
|
||||
</div>
|
||||
<!-- End sidebar + main content wrapper -->
|
||||
</div>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.CnMaestro.Client, as: CnMaestroClient
|
||||
alias Towerops.Gaiia.Client, as: GaiiaClient
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Preseem.Client, as: PreseemClient
|
||||
alias Towerops.Sonar.Client, as: SonarClient
|
||||
alias Towerops.Splynx.Client, as: SplynxClient
|
||||
alias Towerops.CnMaestro.Client, as: CnMaestroClient
|
||||
alias Towerops.Uisp.Client, as: UispClient
|
||||
alias Towerops.Visp.Client, as: VispClient
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defmodule ToweropsWeb.ReportsLive do
|
|||
|
||||
alias Towerops.Reports
|
||||
alias Towerops.Reports.Report
|
||||
alias Towerops.Workers.ReportWorker
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -103,7 +104,7 @@ defmodule ToweropsWeb.ReportsLive do
|
|||
@impl true
|
||||
def handle_event("run_now", %{"id" => id}, socket) do
|
||||
case %{"report_id" => id}
|
||||
|> Towerops.Workers.ReportWorker.new()
|
||||
|> ReportWorker.new()
|
||||
|> Oban.insert() do
|
||||
{:ok, _} ->
|
||||
{:noreply, put_flash(socket, :info, "Report queued for delivery")}
|
||||
|
|
@ -127,15 +128,12 @@ defmodule ToweropsWeb.ReportsLive do
|
|||
defp format_recipients(recipients) when is_list(recipients), do: Enum.join(recipients, ", ")
|
||||
defp format_recipients(_), do: "-"
|
||||
|
||||
defp status_badge("success"),
|
||||
do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
|
||||
defp status_badge("success"), do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
|
||||
|
||||
defp status_badge("failed"),
|
||||
do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
|
||||
defp status_badge("failed"), do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
|
||||
|
||||
defp status_badge("partial_failure"),
|
||||
do: {"Partial", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"}
|
||||
|
||||
defp status_badge(_),
|
||||
do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"}
|
||||
defp status_badge(_), do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@
|
|||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Create Report")}</h2>
|
||||
<button phx-click="hide_form" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<button
|
||||
phx-click="hide_form"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -100,7 +103,9 @@
|
|||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Recipients")}
|
||||
<span class="font-normal text-gray-400">{t("(comma-separated email addresses)")}</span>
|
||||
<span class="font-normal text-gray-400">
|
||||
{t("(comma-separated email addresses)")}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="report[recipients_text]"
|
||||
|
|
@ -190,7 +195,10 @@
|
|||
title={if report.enabled, do: "Disable", else: "Enable"}
|
||||
class="text-gray-400 hover:text-yellow-600 dark:hover:text-yellow-400"
|
||||
>
|
||||
<.icon name={if report.enabled, do: "hero-pause", else: "hero-play"} class="w-4 h-4" />
|
||||
<.icon
|
||||
name={if report.enabled, do: "hero-pause", else: "hero-play"}
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete"
|
||||
|
|
@ -211,8 +219,13 @@
|
|||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12 text-center">
|
||||
<.icon name="hero-document-chart-bar" class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
|
||||
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">{t("No scheduled reports")}</h3>
|
||||
<.icon
|
||||
name="hero-document-chart-bar"
|
||||
class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("No scheduled reports")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Create a report to start receiving automated updates via email.")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -138,8 +138,7 @@ defmodule ToweropsWeb.RfLinkHealthLive do
|
|||
|
||||
defp format_rate(nil), do: "-"
|
||||
|
||||
defp format_rate(rate) when rate >= 1000,
|
||||
do: "#{Float.round(rate / 1000, 1)} Gbps"
|
||||
defp format_rate(rate) when rate >= 1000, do: "#{Float.round(rate / 1000, 1)} Gbps"
|
||||
|
||||
defp format_rate(rate), do: "#{rate} Mbps"
|
||||
|
||||
|
|
@ -150,8 +149,8 @@ defmodule ToweropsWeb.RfLinkHealthLive do
|
|||
defp format_uptime(nil), do: "-"
|
||||
|
||||
defp format_uptime(seconds) do
|
||||
days = div(seconds, 86400)
|
||||
hours = div(rem(seconds, 86400), 3600)
|
||||
days = div(seconds, 86_400)
|
||||
hours = div(rem(seconds, 86_400), 3600)
|
||||
|
||||
cond do
|
||||
days > 0 -> "#{days}d #{hours}h"
|
||||
|
|
@ -175,7 +174,7 @@ defmodule ToweropsWeb.RfLinkHealthLive do
|
|||
defp build_sparkline_points(readings) when length(readings) < 2, do: nil
|
||||
|
||||
defp build_sparkline_points(readings) do
|
||||
signals = Enum.map(readings, & &1.signal_strength) |> Enum.reject(&is_nil/1)
|
||||
signals = readings |> Enum.map(& &1.signal_strength) |> Enum.reject(&is_nil/1)
|
||||
|
||||
if signals == [] do
|
||||
nil
|
||||
|
|
@ -190,12 +189,11 @@ defmodule ToweropsWeb.RfLinkHealthLive do
|
|||
points =
|
||||
signals
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {val, i} ->
|
||||
|> Enum.map_join(" ", fn {val, i} ->
|
||||
x = round(i / max(length(signals) - 1, 1) * width)
|
||||
y = round((1 - (val - min_val) / range) * height)
|
||||
"#{x},#{y}"
|
||||
end)
|
||||
|> Enum.join(" ")
|
||||
|
||||
%{points: points, width: width, height: height}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -234,24 +234,34 @@
|
|||
<dl class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{t("Distance")}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">{format_distance(link.distance)}</dd>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">
|
||||
{format_distance(link.distance)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{t("TX Rate")}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">{format_rate(link.tx_rate)}</dd>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">
|
||||
{format_rate(link.tx_rate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{t("RX Rate")}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">{format_rate(link.rx_rate)}</dd>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">
|
||||
{format_rate(link.rx_rate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{t("Uptime")}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">{format_uptime(link.uptime_seconds)}</dd>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">
|
||||
{format_uptime(link.uptime_seconds)}
|
||||
</dd>
|
||||
</div>
|
||||
<%= if link.capacity_utilization do %>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{t("Capacity")}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">{link.capacity_utilization}%</dd>
|
||||
<dd class="text-gray-900 dark:text-white font-medium">
|
||||
{link.capacity_utilization}%
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
|
|
@ -290,7 +300,9 @@
|
|||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12 text-center">
|
||||
<.icon name="hero-signal" class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
|
||||
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">{t("No RF links found")}</h3>
|
||||
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("No RF links found")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("RF links will appear here when wireless clients are discovered via SNMP polling.")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ defmodule ToweropsWeb.StatusPageLive do
|
|||
|> assign(:recent_incidents, recent_incidents)
|
||||
|> assign(:overall_status, overall)
|
||||
|> assign(:not_found, false)
|
||||
|> assign(:page_title, "#{config.company_name || "Status"} — System Status"),
|
||||
layout: false}
|
||||
|> assign(:page_title, "#{config.company_name || "Status"} — System Status"), layout: false}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
<body class="bg-gray-50 flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Status Page Not Found</h1>
|
||||
<p class="mt-2 text-gray-600">The requested status page does not exist or is not enabled.</p>
|
||||
<p class="mt-2 text-gray-600">
|
||||
The requested status page does not exist or is not enabled.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -19,9 +21,12 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{@page_title}</title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static src={~p"/assets/app.js"}></script>
|
||||
<script defer phx-track-static src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
<%= if @config.custom_css do %>
|
||||
<style>{@config.custom_css}</style>
|
||||
<style>
|
||||
{@config.custom_css}
|
||||
</style>
|
||||
<% end %>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen">
|
||||
|
|
@ -47,7 +52,9 @@
|
|||
<%!-- Active Incidents --%>
|
||||
<%= if @active_incidents != [] do %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Active Incidents</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Active Incidents
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<%= for incident <- @active_incidents do %>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
|
|
@ -57,7 +64,9 @@
|
|||
{incident.title}
|
||||
</h3>
|
||||
<%= if incident.body do %>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{incident.body}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{incident.body}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300">
|
||||
|
|
@ -85,13 +94,18 @@
|
|||
<%= for component <- @components do %>
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{component.name}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{component.name}
|
||||
</span>
|
||||
<%= if component.description do %>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{component.description}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{component.description}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={"w-2.5 h-2.5 rounded-full #{component_color(component.status)}"}></div>
|
||||
<div class={"w-2.5 h-2.5 rounded-full #{component_color(component.status)}"}>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{component_label(component.status)}
|
||||
</span>
|
||||
|
|
@ -104,7 +118,9 @@
|
|||
|
||||
<%!-- Recent Incidents --%>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Recent Incidents</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent Incidents
|
||||
</h2>
|
||||
<%= if @recent_incidents == [] do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
No recent incidents — looking good!
|
||||
|
|
@ -114,7 +130,9 @@
|
|||
<%= for incident <- @recent_incidents do %>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{incident.title}</h3>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{incident.title}
|
||||
</h3>
|
||||
<span class={"text-xs px-2 py-0.5 rounded font-medium " <> if(incident.status == "resolved", do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", else: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300")}>
|
||||
{incident_status_label(incident.status)}
|
||||
</span>
|
||||
|
|
@ -135,7 +153,13 @@
|
|||
<footer class="text-center text-xs text-gray-400 dark:text-gray-600 mt-12">
|
||||
<%= if @config.support_email do %>
|
||||
<p>
|
||||
Need help? Contact <a href={"mailto:#{@config.support_email}"} class="underline hover:text-gray-600 dark:hover:text-gray-400">{@config.support_email}</a>
|
||||
Need help? Contact
|
||||
<a
|
||||
href={"mailto:#{@config.support_email}"}
|
||||
class="underline hover:text-gray-600 dark:hover:text-gray-400"
|
||||
>
|
||||
{@config.support_email}
|
||||
</a>
|
||||
</p>
|
||||
<% end %>
|
||||
<p class="mt-1">Powered by Towerops</p>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ defmodule Towerops.CnMaestro.ClientTest do
|
|||
})
|
||||
|
||||
_ ->
|
||||
conn |> Plug.Conn.send_resp(401, "Unauthorized")
|
||||
Plug.Conn.send_resp(conn, 401, "Unauthorized")
|
||||
end
|
||||
|
||||
{"GET", "/api/v1/networks"} ->
|
||||
|
|
@ -42,7 +42,7 @@ defmodule Towerops.CnMaestro.ClientTest do
|
|||
})
|
||||
|
||||
_ ->
|
||||
conn |> Plug.Conn.send_resp(404, "Not Found")
|
||||
Plug.Conn.send_resp(conn, 404, "Not Found")
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Towerops.ReportsTest do
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Reports
|
||||
alias Towerops.Reports.Report
|
||||
|
||||
|
|
@ -147,7 +148,7 @@ defmodule Towerops.ReportsTest do
|
|||
assert is_nil(report.last_run_at)
|
||||
{:ok, updated} = Reports.mark_run(report, "success")
|
||||
assert updated.last_run_status == "success"
|
||||
assert not is_nil(updated.last_run_at)
|
||||
assert updated.last_run_at
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -155,8 +156,8 @@ defmodule Towerops.ReportsTest do
|
|||
|
||||
defp insert_org do
|
||||
{:ok, org} =
|
||||
%Towerops.Organizations.Organization{}
|
||||
|> Towerops.Organizations.Organization.changeset(%{
|
||||
%Organization{}
|
||||
|> Organization.changeset(%{
|
||||
name: "Test Org #{System.unique_integer([:positive])}",
|
||||
slug: "test-org-#{System.unique_integer([:positive])}"
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
defmodule Towerops.RfLinksTest do
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.RfLinks
|
||||
alias Towerops.Snmp.WirelessClient
|
||||
|
||||
describe "list_rf_links/2" do
|
||||
test "returns empty list for org with no wireless clients", %{} do
|
||||
|
|
@ -83,8 +86,8 @@ defmodule Towerops.RfLinksTest do
|
|||
|
||||
defp insert_org do
|
||||
{:ok, org} =
|
||||
%Towerops.Organizations.Organization{}
|
||||
|> Towerops.Organizations.Organization.changeset(%{
|
||||
%Organization{}
|
||||
|> Organization.changeset(%{
|
||||
name: "Test Org #{System.unique_integer([:positive])}",
|
||||
slug: "test-org-#{System.unique_integer([:positive])}"
|
||||
})
|
||||
|
|
@ -95,11 +98,11 @@ defmodule Towerops.RfLinksTest do
|
|||
|
||||
defp insert_device(org) do
|
||||
{:ok, device} =
|
||||
%Towerops.Devices.Device{}
|
||||
|> Towerops.Devices.Device.changeset(%{
|
||||
%Device{}
|
||||
|> Device.changeset(%{
|
||||
organization_id: org.id,
|
||||
name: "Test AP",
|
||||
ip_address: "10.10.10.#{System.unique_integer([:positive]) |> rem(255)}",
|
||||
ip_address: "10.10.10.#{[:positive] |> System.unique_integer() |> rem(255)}",
|
||||
snmp_enabled: true,
|
||||
monitoring_enabled: true
|
||||
})
|
||||
|
|
@ -112,16 +115,14 @@ defmodule Towerops.RfLinksTest do
|
|||
base = %{
|
||||
device_id: device.id,
|
||||
organization_id: org.id,
|
||||
last_seen_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
|
||||
{:ok, client} =
|
||||
%Towerops.Snmp.WirelessClient{}
|
||||
|> Towerops.Snmp.WirelessClient.changeset(Map.merge(base, attrs))
|
||||
%WirelessClient{}
|
||||
|> WirelessClient.changeset(Map.merge(base, attrs))
|
||||
|> Towerops.Repo.insert()
|
||||
|
||||
client
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Towerops.StatusPagesTest do
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.StatusPages
|
||||
|
||||
describe "config" do
|
||||
|
|
@ -84,7 +85,7 @@ defmodule Towerops.StatusPagesTest do
|
|||
title: "Network Outage",
|
||||
severity: "major",
|
||||
status: "investigating",
|
||||
started_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
started_at: DateTime.truncate(DateTime.utc_now(), :second),
|
||||
status_page_config_id: config.id
|
||||
})
|
||||
|
||||
|
|
@ -95,7 +96,7 @@ defmodule Towerops.StatusPagesTest do
|
|||
|
||||
{:ok, resolved} = StatusPages.resolve_incident(incident)
|
||||
assert resolved.status == "resolved"
|
||||
assert not is_nil(resolved.resolved_at)
|
||||
assert resolved.resolved_at
|
||||
|
||||
active_after = StatusPages.list_active_incidents(config.id)
|
||||
assert active_after == []
|
||||
|
|
@ -108,6 +109,7 @@ defmodule Towerops.StatusPagesTest do
|
|||
%{status: "operational"},
|
||||
%{status: "operational"}
|
||||
]
|
||||
|
||||
assert StatusPages.overall_status(components) == :operational
|
||||
end
|
||||
|
||||
|
|
@ -116,6 +118,7 @@ defmodule Towerops.StatusPagesTest do
|
|||
%{status: "operational"},
|
||||
%{status: "major_outage"}
|
||||
]
|
||||
|
||||
assert StatusPages.overall_status(components) == :major_outage
|
||||
end
|
||||
|
||||
|
|
@ -124,14 +127,15 @@ defmodule Towerops.StatusPagesTest do
|
|||
%{status: "operational"},
|
||||
%{status: "degraded"}
|
||||
]
|
||||
|
||||
assert StatusPages.overall_status(components) == :degraded
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_org do
|
||||
{:ok, org} =
|
||||
%Towerops.Organizations.Organization{}
|
||||
|> Towerops.Organizations.Organization.changeset(%{
|
||||
%Organization{}
|
||||
|> Organization.changeset(%{
|
||||
name: "Test Org #{System.unique_integer([:positive])}",
|
||||
slug: "test-org-#{System.unique_integer([:positive])}"
|
||||
})
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ defmodule Towerops.Uisp.SyncTest do
|
|||
|
||||
updated = Repo.get!(Integration, integration.id)
|
||||
assert updated.last_sync_status == "success"
|
||||
assert updated.last_synced_at != nil
|
||||
assert updated.last_synced_at
|
||||
end
|
||||
|
||||
test "handles API errors and writes failed sync log", %{integration: integration} do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue