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:
Graham McIntire 2026-03-15 18:19:09 -05:00 committed by graham
parent 42bfc8b9d2
commit 7c8731f86a
31 changed files with 914 additions and 756 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)))

View file

@ -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

View file

@ -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)

View file

@ -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}

View file

@ -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}

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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} />

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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)

View file

@ -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])}"
})

View file

@ -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

View file

@ -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])}"
})

View file

@ -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