Prevent Oban polling/monitoring job stacking per device
When the Oban queue backs up, the 60-second uniqueness window expires and duplicate jobs stack up per device. Switch to period: :infinity so only one poll/monitor job exists per device at any time. Add replace option to supersede stale scheduled jobs with updated scheduled_at. Remove :executing from unique states so self-scheduling works while the current job runs. Set max_attempts: 1 since retrying stale polls is pointless. Also fix all credo --strict issues across the codebase.
This commit is contained in:
parent
09c56e3e9b
commit
de986bddf6
38 changed files with 1919 additions and 1336 deletions
|
|
@ -1 +1 @@
|
|||
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
|
||||
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
|
||||
|
|
@ -289,7 +289,9 @@ defmodule Towerops.Gaiia do
|
|||
|
||||
mrr =
|
||||
BillingSubscription
|
||||
|> join(:inner, [bs], a in Account, on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id)
|
||||
|> join(:inner, [bs], a in Account,
|
||||
on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id
|
||||
)
|
||||
|> where([bs, a], a.id in ^account_ids)
|
||||
|> where([bs], bs.status == "ACTIVE")
|
||||
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
|
||||
|
|
@ -333,8 +335,6 @@ defmodule Towerops.Gaiia do
|
|||
Returns `%{subscriber_count: integer, mrr: Decimal.t(), devices: [%{device_id, name, count, mrr}]}`.
|
||||
"""
|
||||
def get_site_impact(site_id) do
|
||||
alias Towerops.Devices.Device
|
||||
|
||||
device_ids =
|
||||
Device
|
||||
|> where(site_id: ^site_id)
|
||||
|
|
@ -365,7 +365,9 @@ defmodule Towerops.Gaiia do
|
|||
Decimal.new(0)
|
||||
else
|
||||
BillingSubscription
|
||||
|> join(:inner, [bs], a in Account, on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id)
|
||||
|> join(:inner, [bs], a in Account,
|
||||
on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id
|
||||
)
|
||||
|> where([bs, a], a.id in ^total_account_ids)
|
||||
|> where([bs], bs.status == "ACTIVE")
|
||||
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
Deletes existing links and re-computes from ARP + inventory data.
|
||||
"""
|
||||
def refresh_device_links(organization_id) do
|
||||
unless has_gaiia_integration?(organization_id) do
|
||||
if has_gaiia_integration?(organization_id) do
|
||||
do_refresh(organization_id)
|
||||
else
|
||||
Logger.debug("Skipping subscriber matching for org #{organization_id} — no Gaiia integration")
|
||||
:ok
|
||||
else
|
||||
do_refresh(organization_id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -42,10 +42,10 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
device = Repo.get(Device, device_id)
|
||||
|
||||
if device do
|
||||
unless has_gaiia_integration?(device.organization_id) do
|
||||
:ok
|
||||
else
|
||||
if has_gaiia_integration?(device.organization_id) do
|
||||
do_refresh_for_device(device)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -101,13 +101,11 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
|
||||
# Deduplicate by {device_id, gaiia_account_id} — first match wins (highest priority)
|
||||
unique_links =
|
||||
all_links
|
||||
|> Enum.uniq_by(fn l -> {l.device_id, l.gaiia_account_id} end)
|
||||
Enum.uniq_by(all_links, fn l -> {l.device_id, l.gaiia_account_id} end)
|
||||
|
||||
# Transaction: delete old + insert new
|
||||
Repo.transaction(fn ->
|
||||
from(d in DeviceSubscriberLink, where: d.organization_id == ^organization_id)
|
||||
|> Repo.delete_all()
|
||||
Repo.delete_all(from(d in DeviceSubscriberLink, where: d.organization_id == ^organization_id))
|
||||
|
||||
if unique_links != [] do
|
||||
Repo.insert_all(DeviceSubscriberLink, unique_links)
|
||||
|
|
@ -128,62 +126,17 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
org_id = device.organization_id
|
||||
|
||||
items = load_assigned_items(org_id)
|
||||
arp_entries = load_arp_entries_for_device(device.id)
|
||||
wireless_clients = load_wireless_clients_for_device(device.id)
|
||||
|
||||
arp_by_ip = Map.new(arp_entries, fn e -> {e.ip_address, e} end)
|
||||
arp_by_mac = Map.new(arp_entries, fn e -> {normalize_mac(e.mac_address), e} end)
|
||||
wc_by_mac = Map.new(wireless_clients, fn w -> {normalize_mac(w.mac_address), w} end)
|
||||
wc_by_ip = wireless_clients |> Enum.filter(& &1.ip_address) |> Map.new(fn w -> {w.ip_address, w} end)
|
||||
|
||||
# Resolve account IDs from gaiia_id
|
||||
lookups = build_device_lookups(device)
|
||||
account_lookup = load_account_id_lookup(org_id)
|
||||
items = load_assigned_items(org_id)
|
||||
|
||||
links =
|
||||
Enum.flat_map(items, fn item ->
|
||||
cond do
|
||||
# Wireless MAC match (highest confidence)
|
||||
item.mac_address && Map.has_key?(wc_by_mac, normalize_mac(item.mac_address)) ->
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil -> []
|
||||
account_id ->
|
||||
[build_link(org_id, device.id, account_id, item, "wireless_mac", "high", nil, normalize_mac(item.mac_address), now)]
|
||||
end
|
||||
|
||||
# Wireless IP match (high confidence)
|
||||
item.ip_address && Map.has_key?(wc_by_ip, item.ip_address) ->
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil -> []
|
||||
account_id ->
|
||||
[build_link(org_id, device.id, account_id, item, "wireless_ip", "high", item.ip_address, nil, now)]
|
||||
end
|
||||
|
||||
# ARP IP match (medium confidence)
|
||||
item.ip_address && Map.has_key?(arp_by_ip, item.ip_address) ->
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil -> []
|
||||
account_id ->
|
||||
[build_link(org_id, device.id, account_id, item, "arp_ip", "medium", item.ip_address, nil, now)]
|
||||
end
|
||||
|
||||
# ARP MAC match (medium confidence)
|
||||
item.mac_address && Map.has_key?(arp_by_mac, normalize_mac(item.mac_address)) ->
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil -> []
|
||||
account_id ->
|
||||
[build_link(org_id, device.id, account_id, item, "arp_mac", "medium", nil, normalize_mac(item.mac_address), now)]
|
||||
end
|
||||
|
||||
true ->
|
||||
[]
|
||||
end
|
||||
end)
|
||||
items
|
||||
|> Enum.flat_map(&match_item_for_device(&1, device.id, org_id, lookups, account_lookup, now))
|
||||
|> Enum.uniq_by(fn l -> {l.device_id, l.gaiia_account_id} end)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
from(d in DeviceSubscriberLink, where: d.device_id == ^device.id)
|
||||
|> Repo.delete_all()
|
||||
Repo.delete_all(from(d in DeviceSubscriberLink, where: d.device_id == ^device.id))
|
||||
|
||||
if links != [] do
|
||||
Repo.insert_all(DeviceSubscriberLink, links)
|
||||
|
|
@ -193,6 +146,79 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp build_device_lookups(device) do
|
||||
arp_entries = load_arp_entries_for_device(device.id)
|
||||
wireless_clients = load_wireless_clients_for_device(device.id)
|
||||
|
||||
%{
|
||||
arp_by_ip: Map.new(arp_entries, fn e -> {e.ip_address, e} end),
|
||||
arp_by_mac: Map.new(arp_entries, fn e -> {normalize_mac(e.mac_address), e} end),
|
||||
wc_by_mac: Map.new(wireless_clients, fn w -> {normalize_mac(w.mac_address), w} end),
|
||||
wc_by_ip: wireless_clients |> Enum.filter(& &1.ip_address) |> Map.new(fn w -> {w.ip_address, w} end)
|
||||
}
|
||||
end
|
||||
|
||||
defp match_item_for_device(item, device_id, org_id, lookups, account_lookup, now) do
|
||||
case detect_device_match(item, lookups) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
{method, confidence, ip, mac} ->
|
||||
maybe_build_link(account_lookup, item, %{
|
||||
org_id: org_id,
|
||||
device_id: device_id,
|
||||
method: method,
|
||||
confidence: confidence,
|
||||
ip: ip,
|
||||
mac: mac,
|
||||
now: now
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
defp detect_device_match(item, lookups) do
|
||||
normalized_mac = normalize_mac(item.mac_address)
|
||||
|
||||
match_wireless_mac(normalized_mac, lookups) ||
|
||||
match_wireless_ip(item, lookups) ||
|
||||
match_arp_ip(item, lookups) ||
|
||||
match_arp_mac(normalized_mac, lookups)
|
||||
end
|
||||
|
||||
defp match_wireless_mac(nil, _lookups), do: nil
|
||||
|
||||
defp match_wireless_mac(mac, lookups) do
|
||||
if Map.has_key?(lookups.wc_by_mac, mac), do: {"wireless_mac", "high", nil, mac}
|
||||
end
|
||||
|
||||
defp match_wireless_ip(item, lookups) do
|
||||
if item.ip_address && Map.has_key?(lookups.wc_by_ip, item.ip_address),
|
||||
do: {"wireless_ip", "high", item.ip_address, nil}
|
||||
end
|
||||
|
||||
defp match_arp_ip(item, lookups) do
|
||||
if item.ip_address && Map.has_key?(lookups.arp_by_ip, item.ip_address),
|
||||
do: {"arp_ip", "medium", item.ip_address, nil}
|
||||
end
|
||||
|
||||
defp match_arp_mac(nil, _lookups), do: nil
|
||||
|
||||
defp match_arp_mac(mac, lookups) do
|
||||
if Map.has_key?(lookups.arp_by_mac, mac), do: {"arp_mac", "medium", nil, mac}
|
||||
end
|
||||
|
||||
defp maybe_build_link(account_lookup, item, params) do
|
||||
case resolve_account(item, account_lookup) do
|
||||
nil -> []
|
||||
account_id -> [build_link(Map.merge(params, %{account_id: account_id, item: item}))]
|
||||
end
|
||||
end
|
||||
|
||||
# Resolves the local account ID for an inventory item, returning nil if not found.
|
||||
defp resolve_account(item, account_lookup) do
|
||||
Map.get(account_lookup, item.assigned_account_gaiia_id)
|
||||
end
|
||||
|
||||
defp load_assigned_items(organization_id) do
|
||||
InventoryItem
|
||||
|> where(organization_id: ^organization_id)
|
||||
|
|
@ -239,17 +265,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
{matched_ids, links} =
|
||||
Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} ->
|
||||
normalized = normalize_mac(item.mac_address)
|
||||
if normalized && Map.has_key?(wc_by_mac, normalized) do
|
||||
wc = Map.get(wc_by_mac, normalized)
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
account_id ->
|
||||
link = build_link(org_id, wc.device_id, account_id, item, "wireless_mac", "high", nil, normalized, now)
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
else
|
||||
{matched, acc}
|
||||
wc = if normalized, do: Map.get(wc_by_mac, normalized)
|
||||
|
||||
case resolve_match(wc, item, account_lookup) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
|
||||
account_id ->
|
||||
link =
|
||||
build_link(%{
|
||||
org_id: org_id,
|
||||
device_id: wc.device_id,
|
||||
account_id: account_id,
|
||||
item: item,
|
||||
method: "wireless_mac",
|
||||
confidence: "high",
|
||||
ip: nil,
|
||||
mac: normalized,
|
||||
now: now
|
||||
})
|
||||
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -261,17 +297,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
|
||||
{matched_ids, links} =
|
||||
Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} ->
|
||||
if item.ip_address && Map.has_key?(wc_by_ip, item.ip_address) do
|
||||
wc = Map.get(wc_by_ip, item.ip_address)
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
account_id ->
|
||||
link = build_link(org_id, wc.device_id, account_id, item, "wireless_ip", "high", item.ip_address, nil, now)
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
else
|
||||
{matched, acc}
|
||||
wc = if item.ip_address, do: Map.get(wc_by_ip, item.ip_address)
|
||||
|
||||
case resolve_match(wc, item, account_lookup) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
|
||||
account_id ->
|
||||
link =
|
||||
build_link(%{
|
||||
org_id: org_id,
|
||||
device_id: wc.device_id,
|
||||
account_id: account_id,
|
||||
item: item,
|
||||
method: "wireless_ip",
|
||||
confidence: "high",
|
||||
ip: item.ip_address,
|
||||
mac: nil,
|
||||
now: now
|
||||
})
|
||||
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -283,17 +329,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
|
||||
{matched_ids, links} =
|
||||
Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} ->
|
||||
if item.ip_address && Map.has_key?(arp_by_ip, item.ip_address) do
|
||||
arp = Map.get(arp_by_ip, item.ip_address)
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
account_id ->
|
||||
link = build_link(org_id, arp.device_id, account_id, item, "arp_ip", "medium", item.ip_address, nil, now)
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
else
|
||||
{matched, acc}
|
||||
arp = if item.ip_address, do: Map.get(arp_by_ip, item.ip_address)
|
||||
|
||||
case resolve_match(arp, item, account_lookup) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
|
||||
account_id ->
|
||||
link =
|
||||
build_link(%{
|
||||
org_id: org_id,
|
||||
device_id: arp.device_id,
|
||||
account_id: account_id,
|
||||
item: item,
|
||||
method: "arp_ip",
|
||||
confidence: "medium",
|
||||
ip: item.ip_address,
|
||||
mac: nil,
|
||||
now: now
|
||||
})
|
||||
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -306,17 +362,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
{matched_ids, links} =
|
||||
Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} ->
|
||||
normalized = normalize_mac(item.mac_address)
|
||||
if normalized && Map.has_key?(arp_by_mac, normalized) do
|
||||
arp = Map.get(arp_by_mac, normalized)
|
||||
case Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
account_id ->
|
||||
link = build_link(org_id, arp.device_id, account_id, item, "arp_mac", "medium", nil, normalized, now)
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
else
|
||||
{matched, acc}
|
||||
arp = if normalized, do: Map.get(arp_by_mac, normalized)
|
||||
|
||||
case resolve_match(arp, item, account_lookup) do
|
||||
nil ->
|
||||
{matched, acc}
|
||||
|
||||
account_id ->
|
||||
link =
|
||||
build_link(%{
|
||||
org_id: org_id,
|
||||
device_id: arp.device_id,
|
||||
account_id: account_id,
|
||||
item: item,
|
||||
method: "arp_mac",
|
||||
confidence: "medium",
|
||||
ip: nil,
|
||||
mac: normalized,
|
||||
now: now
|
||||
})
|
||||
|
||||
{MapSet.put(matched, item.id), [link | acc]}
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -349,28 +415,50 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
site_id when not is_nil(site_id) <- Map.get(site_mappings, gaiia_site_id),
|
||||
device_ids when device_ids != nil <- Map.get(devices_by_site, site_id),
|
||||
account_id when not is_nil(account_id) <- Map.get(account_lookup, item.assigned_account_gaiia_id) do
|
||||
Enum.map(device_ids, fn device_id ->
|
||||
build_link(org_id, device_id, account_id, item, "site_fallback", "low", nil, nil, now)
|
||||
end)
|
||||
build_site_links(device_ids, org_id, account_id, item, now)
|
||||
else
|
||||
_ -> []
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_link(org_id, device_id, account_id, item, method, confidence, ip, mac, now) do
|
||||
defp build_site_links(device_ids, org_id, account_id, item, now) do
|
||||
Enum.map(device_ids, fn device_id ->
|
||||
build_link(%{
|
||||
org_id: org_id,
|
||||
device_id: device_id,
|
||||
account_id: account_id,
|
||||
item: item,
|
||||
method: "site_fallback",
|
||||
confidence: "low",
|
||||
ip: nil,
|
||||
mac: nil,
|
||||
now: now
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
# Returns the account_id if the record and account exist, nil otherwise.
|
||||
# Used by match_by_* functions to flatten the if/case nesting.
|
||||
defp resolve_match(nil, _item, _account_lookup), do: nil
|
||||
|
||||
defp resolve_match(_record, item, account_lookup) do
|
||||
Map.get(account_lookup, item.assigned_account_gaiia_id)
|
||||
end
|
||||
|
||||
defp build_link(%{} = params) do
|
||||
%{
|
||||
id: Ecto.UUID.generate(),
|
||||
organization_id: org_id,
|
||||
device_id: device_id,
|
||||
gaiia_account_id: account_id,
|
||||
gaiia_inventory_item_id: item.id,
|
||||
match_method: method,
|
||||
confidence: confidence,
|
||||
subscriber_ip: ip,
|
||||
subscriber_mac: mac,
|
||||
inserted_at: now,
|
||||
updated_at: now
|
||||
organization_id: params.org_id,
|
||||
device_id: params.device_id,
|
||||
gaiia_account_id: params.account_id,
|
||||
gaiia_inventory_item_id: params.item.id,
|
||||
match_method: params.method,
|
||||
confidence: params.confidence,
|
||||
subscriber_ip: params.ip,
|
||||
subscriber_mac: params.mac,
|
||||
inserted_at: params.now,
|
||||
updated_at: params.now
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -383,6 +471,7 @@ defmodule Towerops.Gaiia.SubscriberMatching do
|
|||
|> case do
|
||||
<<a::binary-2, b::binary-2, c::binary-2, d::binary-2, e::binary-2, f::binary-2>> ->
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}"
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule Towerops.Gaiia.Sync do
|
|||
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Gaiia.Client
|
||||
alias Towerops.Gaiia.SubscriberMatching
|
||||
alias Towerops.Integrations
|
||||
|
||||
require Logger
|
||||
|
|
@ -39,7 +40,7 @@ defmodule Towerops.Gaiia.Sync do
|
|||
Integrations.update_sync_status(integration, "success", message)
|
||||
persist_billing_totals(integration)
|
||||
Gaiia.SiteAggregation.compute_and_store(org_id)
|
||||
Towerops.Gaiia.SubscriberMatching.refresh_device_links(org_id)
|
||||
SubscriberMatching.refresh_device_links(org_id)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -118,8 +118,7 @@ defmodule Towerops.Maintenance do
|
|||
site_results = Repo.all(site_query)
|
||||
org_results = Repo.all(org_wide)
|
||||
|
||||
(direct_results ++ site_results ++ org_results)
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
Enum.uniq_by(direct_results ++ site_results ++ org_results, & &1.id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -72,16 +72,13 @@ defmodule Towerops.Organizations.Policy do
|
|||
defp check_permission(:technician, action, :integration) when action in [:create, :edit, :delete], do: false
|
||||
|
||||
defp check_permission(:technician, action, resource)
|
||||
when action in [:view, :list, :create, :edit] and
|
||||
resource in [:site, :device, :alert],
|
||||
do: true
|
||||
when action in [:view, :list, :create, :edit] and resource in [:site, :device, :alert], do: true
|
||||
|
||||
defp check_permission(:technician, :acknowledge, :alert), do: true
|
||||
defp check_permission(:technician, _action, _resource), do: false
|
||||
|
||||
# ── Member (legacy, same as technician) ────────────────────────────────
|
||||
defp check_permission(:member, action, resource),
|
||||
do: check_permission(:technician, action, resource)
|
||||
defp check_permission(:member, action, resource), do: check_permission(:technician, action, resource)
|
||||
|
||||
# ── Viewer (read-only, no financials) ──────────────────────────────────
|
||||
defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@ defmodule Towerops.Sites do
|
|||
Used by the weather sync worker to fetch weather data.
|
||||
"""
|
||||
def list_sites_with_coordinates do
|
||||
from(s in Site,
|
||||
where: not is_nil(s.latitude) and not is_nil(s.longitude),
|
||||
select: s
|
||||
)
|
||||
|> Repo.all()
|
||||
Repo.all(from(s in Site, where: not is_nil(s.latitude) and not is_nil(s.longitude), select: s))
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ defmodule Towerops.Snmp do
|
|||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Discovery
|
||||
alias Towerops.Snmp.WirelessClient
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.Snmp.IpAddress
|
||||
|
|
@ -27,6 +26,7 @@ defmodule Towerops.Snmp do
|
|||
alias Towerops.Snmp.Storage
|
||||
alias Towerops.Snmp.StorageReading
|
||||
alias Towerops.Snmp.Vlan
|
||||
alias Towerops.Snmp.WirelessClient
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -2327,8 +2327,7 @@ defmodule Towerops.Snmp do
|
|||
Returns `{success_count, error_count}`.
|
||||
"""
|
||||
def upsert_wireless_clients(device_id, organization_id, clients) do
|
||||
clients
|
||||
|> Enum.reduce({0, 0}, fn client, {s, e} ->
|
||||
Enum.reduce(clients, {0, 0}, fn client, {s, e} ->
|
||||
attrs =
|
||||
client
|
||||
|> Map.put(:device_id, device_id)
|
||||
|
|
|
|||
|
|
@ -30,10 +30,17 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
@spec discover_wireless_clients(Client.connection_opts(), String.t()) :: {:ok, [map()]}
|
||||
def discover_wireless_clients(client_opts, vendor_profile) do
|
||||
cond do
|
||||
vendor_profile in ["epmp", "cambium", "pmp"] -> discover_epmp_clients(client_opts)
|
||||
vendor_profile in ["airos", "airfiber", "airos-af", "airos-af60", "airos-af-ltu"] -> discover_ubnt_clients(client_opts)
|
||||
vendor_profile in ["routeros", "mikrotik"] -> discover_mikrotik_clients(client_opts)
|
||||
true -> {:ok, []}
|
||||
vendor_profile in ["epmp", "cambium", "pmp"] ->
|
||||
discover_epmp_clients(client_opts)
|
||||
|
||||
vendor_profile in ["airos", "airfiber", "airos-af", "airos-af60", "airos-af-ltu"] ->
|
||||
discover_ubnt_clients(client_opts)
|
||||
|
||||
vendor_profile in ["routeros", "mikrotik"] ->
|
||||
discover_mikrotik_clients(client_opts)
|
||||
|
||||
true ->
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -45,12 +52,24 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
sys_oid = snmp_device.sys_object_id || ""
|
||||
manufacturer = String.downcase(snmp_device.manufacturer || "")
|
||||
|
||||
cond do
|
||||
String.starts_with?(sys_oid, "1.3.6.1.4.1.17713") or String.contains?(manufacturer, "cambium") -> "epmp"
|
||||
String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or String.contains?(manufacturer, "ubiquiti") -> "airos"
|
||||
String.starts_with?(sys_oid, "1.3.6.1.4.1.14988") or String.contains?(manufacturer, "mikrotik") -> "routeros"
|
||||
true -> nil
|
||||
end
|
||||
detect_cambium(sys_oid, manufacturer) ||
|
||||
detect_ubiquiti(sys_oid, manufacturer) ||
|
||||
detect_mikrotik(sys_oid, manufacturer)
|
||||
end
|
||||
|
||||
defp detect_cambium(sys_oid, manufacturer) do
|
||||
if String.starts_with?(sys_oid, "1.3.6.1.4.1.17713") or String.contains?(manufacturer, "cambium"),
|
||||
do: "epmp"
|
||||
end
|
||||
|
||||
defp detect_ubiquiti(sys_oid, manufacturer) do
|
||||
if String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or String.contains?(manufacturer, "ubiquiti"),
|
||||
do: "airos"
|
||||
end
|
||||
|
||||
defp detect_mikrotik(sys_oid, manufacturer) do
|
||||
if String.starts_with?(sys_oid, "1.3.6.1.4.1.14988") or String.contains?(manufacturer, "mikrotik"),
|
||||
do: "routeros"
|
||||
end
|
||||
|
||||
# --- Cambium ePMP ---
|
||||
|
|
@ -75,15 +94,17 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
|> Enum.map(fn {idx, mac_raw} ->
|
||||
%{
|
||||
mac_address: format_mac(mac_raw),
|
||||
ip_address: get_indexed_value(ips, "#{@epmp_base}.10", idx) |> format_ip(),
|
||||
signal_strength: best_value(
|
||||
get_indexed_value(dl_rssi, "#{@epmp_base}.5", idx),
|
||||
get_indexed_value(ul_rssi, "#{@epmp_base}.4", idx)
|
||||
),
|
||||
snr: best_value(
|
||||
get_indexed_value(dl_snr, "#{@epmp_base}.7", idx),
|
||||
get_indexed_value(ul_snr, "#{@epmp_base}.6", idx)
|
||||
),
|
||||
ip_address: ips |> get_indexed_value("#{@epmp_base}.10", idx) |> format_ip(),
|
||||
signal_strength:
|
||||
best_value(
|
||||
get_indexed_value(dl_rssi, "#{@epmp_base}.5", idx),
|
||||
get_indexed_value(ul_rssi, "#{@epmp_base}.4", idx)
|
||||
),
|
||||
snr:
|
||||
best_value(
|
||||
get_indexed_value(dl_snr, "#{@epmp_base}.7", idx),
|
||||
get_indexed_value(ul_snr, "#{@epmp_base}.6", idx)
|
||||
),
|
||||
distance: get_indexed_int(distances, "#{@epmp_base}.29", idx),
|
||||
tx_rate: nil,
|
||||
rx_rate: nil,
|
||||
|
|
@ -125,7 +146,7 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
|> Enum.map(fn {idx, mac_raw} ->
|
||||
%{
|
||||
mac_address: format_mac(mac_raw),
|
||||
ip_address: get_indexed_value(ips, "#{@ubnt_base}.10", idx) |> format_ip(),
|
||||
ip_address: ips |> get_indexed_value("#{@ubnt_base}.10", idx) |> format_ip(),
|
||||
hostname: get_indexed_string(names, "#{@ubnt_base}.2", idx),
|
||||
signal_strength: get_indexed_int(signals, "#{@ubnt_base}.3", idx),
|
||||
snr: nil,
|
||||
|
|
@ -255,15 +276,13 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
defp format_ip(value) when is_binary(value) do
|
||||
if String.match?(value, ~r/^\d+\.\d+\.\d+\.\d+$/) do
|
||||
value
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ip(_), do: nil
|
||||
|
||||
# Pick the best (non-nil) of two values, preferring the first
|
||||
defp best_value(nil, b), do: if(is_integer(b), do: b, else: nil)
|
||||
defp best_value(nil, b), do: if(is_integer(b), do: b)
|
||||
defp best_value(a, _) when is_integer(a), do: a
|
||||
defp best_value(_, b) when is_integer(b), do: b
|
||||
defp best_value(_, _), do: nil
|
||||
|
|
|
|||
|
|
@ -82,9 +82,7 @@ defmodule Towerops.Weather do
|
|||
def cleanup_old_observations(keep_days \\ 30) do
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -keep_days, :day)
|
||||
|
||||
{count, _} =
|
||||
from(o in Observation, where: o.observed_at < ^cutoff)
|
||||
|> Repo.delete_all()
|
||||
{count, _} = Repo.delete_all(from(o in Observation, where: o.observed_at < ^cutoff))
|
||||
|
||||
Logger.info("Cleaned up #{count} old weather observations")
|
||||
{:ok, count}
|
||||
|
|
@ -120,14 +118,15 @@ defmodule Towerops.Weather do
|
|||
def active_alerts_for_org(organization_id) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
from(a in WeatherAlert,
|
||||
join: s in assoc(a, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: is_nil(a.ends_at) or a.ends_at > ^now,
|
||||
order_by: [desc: a.inserted_at],
|
||||
preload: [:site]
|
||||
Repo.all(
|
||||
from(a in WeatherAlert,
|
||||
join: s in assoc(a, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: is_nil(a.ends_at) or a.ends_at > ^now,
|
||||
order_by: [desc: a.inserted_at],
|
||||
preload: [:site]
|
||||
)
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
# ── Fetching ─────────────────────────────────────────────────────────
|
||||
|
|
@ -200,14 +199,8 @@ defmodule Towerops.Weather do
|
|||
- Snow > 5mm/h — ice loading on equipment
|
||||
"""
|
||||
def severe?(%Observation{} = obs) do
|
||||
cond do
|
||||
(obs.wind_speed_ms || 0) > 15 -> true
|
||||
(obs.wind_gust_ms || 0) > 20 -> true
|
||||
(obs.rain_1h_mm || 0) > 10 -> true
|
||||
(obs.snow_1h_mm || 0) > 5 -> true
|
||||
obs.condition in ["Thunderstorm"] -> true
|
||||
true -> false
|
||||
end
|
||||
high_wind?(obs) or strong_gust?(obs) or heavy_rain?(obs) or
|
||||
heavy_snow?(obs) or thunderstorm?(obs)
|
||||
end
|
||||
|
||||
def severe?(_), do: false
|
||||
|
|
@ -218,18 +211,30 @@ defmodule Towerops.Weather do
|
|||
"""
|
||||
def severity(%Observation{} = obs) do
|
||||
cond do
|
||||
(obs.wind_gust_ms || 0) > 25 -> :severe
|
||||
obs.condition in ["Thunderstorm"] -> :severe
|
||||
(obs.rain_1h_mm || 0) > 20 -> :severe
|
||||
(obs.wind_speed_ms || 0) > 15 -> :moderate
|
||||
(obs.wind_gust_ms || 0) > 20 -> :moderate
|
||||
(obs.rain_1h_mm || 0) > 10 -> :moderate
|
||||
(obs.snow_1h_mm || 0) > 5 -> :moderate
|
||||
obs.condition in ["Rain", "Drizzle", "Snow"] -> :mild
|
||||
(obs.clouds_pct || 0) > 80 -> :mild
|
||||
severe_severity?(obs) -> :severe
|
||||
moderate_severity?(obs) -> :moderate
|
||||
mild_severity?(obs) -> :mild
|
||||
true -> :clear
|
||||
end
|
||||
end
|
||||
|
||||
def severity(_), do: :clear
|
||||
|
||||
defp high_wind?(obs), do: (obs.wind_speed_ms || 0) > 15
|
||||
defp strong_gust?(obs), do: (obs.wind_gust_ms || 0) > 20
|
||||
defp heavy_rain?(obs), do: (obs.rain_1h_mm || 0) > 10
|
||||
defp heavy_snow?(obs), do: (obs.snow_1h_mm || 0) > 5
|
||||
defp thunderstorm?(obs), do: obs.condition in ["Thunderstorm"]
|
||||
|
||||
defp severe_severity?(obs) do
|
||||
(obs.wind_gust_ms || 0) > 25 or thunderstorm?(obs) or (obs.rain_1h_mm || 0) > 20
|
||||
end
|
||||
|
||||
defp moderate_severity?(obs) do
|
||||
high_wind?(obs) or strong_gust?(obs) or heavy_rain?(obs) or heavy_snow?(obs)
|
||||
end
|
||||
|
||||
defp mild_severity?(obs) do
|
||||
obs.condition in ["Rain", "Drizzle", "Snow"] or (obs.clouds_pct || 0) > 80
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,16 +23,7 @@ defmodule Towerops.Weather.Client do
|
|||
else
|
||||
url = "#{@base_url}/weather"
|
||||
|
||||
req_opts =
|
||||
[
|
||||
url: url,
|
||||
params: [
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
appid: api_key
|
||||
]
|
||||
]
|
||||
|> maybe_add_test_plug(opts)
|
||||
req_opts = maybe_add_test_plug([url: url, params: [lat: lat, lon: lon, appid: api_key]], opts)
|
||||
|
||||
case Req.get(req_opts) do
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
|
|
|
|||
|
|
@ -73,35 +73,50 @@ defmodule Towerops.Weather.Observation do
|
|||
def from_owm(site_id, %{} = data) do
|
||||
weather = List.first(data["weather"] || []) || %{}
|
||||
main = data["main"] || %{}
|
||||
wind = data["wind"] || %{}
|
||||
rain = data["rain"] || %{}
|
||||
snow = data["snow"] || %{}
|
||||
clouds = data["clouds"] || %{}
|
||||
|
||||
observed_at =
|
||||
case data["dt"] do
|
||||
ts when is_integer(ts) -> DateTime.from_unix!(ts)
|
||||
_ -> DateTime.utc_now(:second)
|
||||
end
|
||||
|
||||
%{
|
||||
site_id: site_id,
|
||||
condition: weather["main"],
|
||||
condition_detail: weather["description"],
|
||||
icon: weather["icon"],
|
||||
visibility_m: data["visibility"],
|
||||
raw: data,
|
||||
observed_at: parse_observed_at(data["dt"])
|
||||
}
|
||||
|> Map.merge(parse_main(main))
|
||||
|> Map.merge(parse_wind(data["wind"] || %{}))
|
||||
|> Map.merge(parse_precipitation(data))
|
||||
end
|
||||
|
||||
defp parse_observed_at(ts) when is_integer(ts), do: DateTime.from_unix!(ts)
|
||||
defp parse_observed_at(_), do: DateTime.utc_now(:second)
|
||||
|
||||
defp parse_main(main) do
|
||||
%{
|
||||
temperature_c: kelvin_to_celsius(main["temp"]),
|
||||
feels_like_c: kelvin_to_celsius(main["feels_like"]),
|
||||
humidity: main["humidity"],
|
||||
pressure_hpa: main["pressure"],
|
||||
visibility_m: data["visibility"],
|
||||
pressure_hpa: main["pressure"]
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_wind(wind) do
|
||||
%{
|
||||
wind_speed_ms: wind["speed"],
|
||||
wind_gust_ms: wind["gust"],
|
||||
wind_deg: wind["deg"],
|
||||
wind_deg: wind["deg"]
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_precipitation(data) do
|
||||
rain = data["rain"] || %{}
|
||||
snow = data["snow"] || %{}
|
||||
clouds = data["clouds"] || %{}
|
||||
|
||||
%{
|
||||
rain_1h_mm: rain["1h"],
|
||||
snow_1h_mm: snow["1h"],
|
||||
clouds_pct: clouds["all"],
|
||||
raw: data,
|
||||
observed_at: observed_at
|
||||
clouds_pct: clouds["all"]
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,15 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
"""
|
||||
use Oban.Worker,
|
||||
queue: :monitors,
|
||||
max_attempts: 1,
|
||||
unique: [
|
||||
period: 60,
|
||||
period: :infinity,
|
||||
keys: [:device_id],
|
||||
states: [:available, :scheduled, :executing, :retryable]
|
||||
states: [:available, :scheduled, :retryable]
|
||||
],
|
||||
replace: [
|
||||
scheduled: [:scheduled_at],
|
||||
available: [:scheduled_at]
|
||||
]
|
||||
|
||||
alias Towerops.Agents
|
||||
|
|
@ -209,27 +214,28 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
defp create_device_down_alert(device, now) do
|
||||
if Maintenance.device_in_maintenance?(device.id) do
|
||||
require Logger
|
||||
|
||||
Logger.info("Device #{device.id} (#{device.name}) alert suppressed by maintenance window (safety net)")
|
||||
:ok
|
||||
else
|
||||
alert_message = get_down_alert_message(device)
|
||||
alert_message = get_down_alert_message(device)
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: now,
|
||||
message: alert_message
|
||||
})
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: now,
|
||||
message: alert_message
|
||||
})
|
||||
|
||||
Task.start(fn -> Notifier.notify_trigger(alert, device) end)
|
||||
Task.start(fn -> Notifier.notify_trigger(alert, device) end)
|
||||
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{device.organization_id}:new",
|
||||
{:new_alert, device.id, :device_down}
|
||||
)
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{device.organization_id}:new",
|
||||
{:new_alert, device.id, :device_down}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,14 +14,20 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
"""
|
||||
use Oban.Worker,
|
||||
queue: :pollers,
|
||||
max_attempts: 1,
|
||||
unique: [
|
||||
period: 60,
|
||||
period: :infinity,
|
||||
keys: [:device_id],
|
||||
states: [:available, :scheduled, :executing, :retryable]
|
||||
states: [:available, :scheduled, :retryable]
|
||||
],
|
||||
replace: [
|
||||
scheduled: [:scheduled_at],
|
||||
available: [:scheduled_at]
|
||||
]
|
||||
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Gaiia.SubscriberMatching
|
||||
alias Towerops.JobMonitoring.Events
|
||||
alias Towerops.Snmp
|
||||
alias Towerops.Snmp.ArpDiscovery
|
||||
|
|
@ -361,7 +367,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
)
|
||||
|
||||
# Refresh subscriber-to-device links based on new ARP data
|
||||
Towerops.Gaiia.SubscriberMatching.refresh_links_for_device(device.id)
|
||||
SubscriberMatching.refresh_links_for_device(device.id)
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Error polling ARP table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
|
||||
|
|
@ -406,12 +412,14 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
)
|
||||
|
||||
# Refresh subscriber-to-device links with new wireless client data
|
||||
Towerops.Gaiia.SubscriberMatching.refresh_links_for_device(device.id)
|
||||
SubscriberMatching.refresh_links_for_device(device.id)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Error polling wireless clients for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
|
||||
Logger.error(
|
||||
"Error polling wireless clients for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
|
||||
)
|
||||
end
|
||||
|
||||
defp poll_device_processors(device, snmp_device, client_opts, now) do
|
||||
|
|
|
|||
|
|
@ -37,29 +37,32 @@ defmodule Towerops.Workers.WeatherSyncWorker do
|
|||
|
||||
if total > 0 do
|
||||
Logger.info("Weather sync: fetching weather for #{total} sites")
|
||||
|
||||
results =
|
||||
sites
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {site, idx} ->
|
||||
# Throttle: ~1 req/sec to stay under 60/min limit
|
||||
if idx > 1, do: Process.sleep(1_100)
|
||||
|
||||
case Weather.fetch_and_store(site) do
|
||||
{:ok, _obs} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Weather fetch failed for site #{site.name}: #{inspect(reason)}")
|
||||
:error
|
||||
end
|
||||
end)
|
||||
|
||||
results = fetch_all_sites(sites)
|
||||
ok_count = Enum.count(results, &(&1 == :ok))
|
||||
Logger.info("Weather sync complete: #{ok_count}/#{total} sites updated")
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_all_sites(sites) do
|
||||
sites
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {site, idx} ->
|
||||
if idx > 1, do: Process.sleep(1_100)
|
||||
fetch_site_weather(site)
|
||||
end)
|
||||
end
|
||||
|
||||
defp fetch_site_weather(site) do
|
||||
case Weather.fetch_and_store(site) do
|
||||
{:ok, _obs} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Weather fetch failed for site #{site.name}: #{inspect(reason)}")
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_next do
|
||||
# Schedule next run in 15 minutes
|
||||
%{} |> new(schedule_in: 900) |> Oban.insert()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ defmodule ToweropsWeb.Components.ConsentPrompt do
|
|||
>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity"></div>
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity">
|
||||
</div>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity"></div>
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity">
|
||||
</div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
<h1 class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{@current_scope.organization.name}
|
||||
</h1>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 dark:text-gray-400 font-mono">NOC</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 dark:text-gray-400 font-mono">
|
||||
NOC
|
||||
</span>
|
||||
</div>
|
||||
<%= if assigns[:last_updated] do %>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500 dark:text-gray-400 font-mono">
|
||||
|
|
@ -282,7 +284,10 @@
|
|||
:for={incident <- Enum.take(@active_incidents, 10)}
|
||||
class={[
|
||||
"group",
|
||||
if(@can_view_financials, do: incident_severity_classes(incident.mrr_at_risk), else: "")
|
||||
if(@can_view_financials,
|
||||
do: incident_severity_classes(incident.mrr_at_risk),
|
||||
else: ""
|
||||
)
|
||||
]}
|
||||
>
|
||||
<td class="w-1 p-0">
|
||||
|
|
@ -754,7 +759,10 @@
|
|||
<div class="hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Config Changes <span class="font-normal text-gray-400 dark:text-gray-500 dark:text-gray-400">(7d)</span>
|
||||
Config Changes
|
||||
<span class="font-normal text-gray-400 dark:text-gray-500 dark:text-gray-400">
|
||||
(7d)
|
||||
</span>
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/insights"}
|
||||
|
|
@ -839,15 +847,24 @@
|
|||
</kbd>
|
||||
<span class="ml-1">to search</span>
|
||||
<span class="mx-2">·</span>
|
||||
<.link navigate={~p"/devices/new"} class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400">
|
||||
<.link
|
||||
navigate={~p"/devices/new"}
|
||||
class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400"
|
||||
>
|
||||
+ device
|
||||
</.link>
|
||||
<span class="mx-2">·</span>
|
||||
<.link navigate={~p"/trace"} class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400">
|
||||
<.link
|
||||
navigate={~p"/trace"}
|
||||
class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400"
|
||||
>
|
||||
trace
|
||||
</.link>
|
||||
<span class="mx-2">·</span>
|
||||
<.link navigate={~p"/network-map"} class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400">
|
||||
<.link
|
||||
navigate={~p"/network-map"}
|
||||
class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400"
|
||||
>
|
||||
map
|
||||
</.link>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,10 @@
|
|||
|
||||
<%= if !@has_devices do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 dark:text-gray-400" />
|
||||
<.icon
|
||||
name="hero-server"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t("No devices")}
|
||||
</h3>
|
||||
|
|
|
|||
|
|
@ -1640,7 +1640,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
defp format_mrr(nil), do: "$0"
|
||||
|
||||
defp format_mrr(%Decimal{} = d) do
|
||||
"$#{Decimal.round(d, 2) |> Decimal.to_string(:normal)}"
|
||||
"$#{d |> Decimal.round(2) |> Decimal.to_string(:normal)}"
|
||||
end
|
||||
|
||||
defp format_mrr(n) when is_number(n), do: "$#{:erlang.float_to_binary(n / 1, decimals: 2)}"
|
||||
|
|
|
|||
|
|
@ -105,12 +105,17 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<.icon name="hero-users" class="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
{@subscriber_impact.subscriber_count} {if @subscriber_impact.subscriber_count == 1, do: "subscriber", else: "subscribers"}
|
||||
{@subscriber_impact.subscriber_count} {if @subscriber_impact.subscriber_count == 1,
|
||||
do: "subscriber",
|
||||
else: "subscribers"}
|
||||
</span>
|
||||
</div>
|
||||
<%= if @can_view_financials && @subscriber_impact.mrr do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon name="hero-currency-dollar" class="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
<.icon
|
||||
name="hero-currency-dollar"
|
||||
class="h-5 w-5 text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
{format_mrr(@subscriber_impact.mrr)}/mo
|
||||
</span>
|
||||
|
|
@ -119,8 +124,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Tabs -->
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-white/10">
|
||||
<nav class="-mb-px flex space-x-8 overflow-x-auto">
|
||||
<.link
|
||||
|
|
@ -675,7 +680,9 @@
|
|||
{s}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{event.change_size} lines</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{event.change_size} lines
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -1807,7 +1814,9 @@
|
|||
{t("Primary")}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">-</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
|
||||
-
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -2463,14 +2472,18 @@
|
|||
<%= if check.last_check_at do %>
|
||||
{get_latest_value(check)}
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">Pending</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
|
||||
Pending
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= if check.last_check_at do %>
|
||||
{format_relative_time(check.last_check_at)}
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">Never</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
|
||||
Never
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -253,21 +253,21 @@ defmodule ToweropsWeb.HelpLive.Index do
|
|||
</.link>
|
||||
</li>
|
||||
<%= if false do %>
|
||||
<li>
|
||||
<.link
|
||||
patch={~p"/help?section=mikrotik"}
|
||||
class={[
|
||||
"block px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
if @active_section == "mikrotik" do
|
||||
"bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
else
|
||||
"text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
end
|
||||
]}
|
||||
>
|
||||
MikroTik
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
patch={~p"/help?section=mikrotik"}
|
||||
class={[
|
||||
"block px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
if @active_section == "mikrotik" do
|
||||
"bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
else
|
||||
"text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
end
|
||||
]}
|
||||
>
|
||||
MikroTik
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
defmodule ToweropsWeb.MaintenanceLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Maintenance
|
||||
|
|
@ -43,8 +44,8 @@ defmodule ToweropsWeb.MaintenanceLive.Index do
|
|||
now = DateTime.utc_now()
|
||||
|
||||
cond do
|
||||
DateTime.compare(window.starts_at, now) == :gt -> :upcoming
|
||||
DateTime.compare(window.ends_at, now) == :lt -> :past
|
||||
DateTime.after?(window.starts_at, now) -> :upcoming
|
||||
DateTime.before?(window.ends_at, now) -> :past
|
||||
true -> :active
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@
|
|||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "all" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "all" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@filter != "all" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("All")}
|
||||
|
|
@ -31,7 +32,8 @@
|
|||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "active" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "active" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@filter != "active" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Active")}
|
||||
|
|
@ -41,7 +43,8 @@
|
|||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "upcoming" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "upcoming" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@filter != "upcoming" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Upcoming")}
|
||||
|
|
@ -51,7 +54,8 @@
|
|||
class={[
|
||||
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "past" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "past" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@filter != "past" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
{t("Past")}
|
||||
|
|
@ -61,8 +65,13 @@
|
|||
<%= if Enum.empty?(@windows) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-wrench-screwdriver" class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">{t("No maintenance windows")}</h3>
|
||||
<.icon
|
||||
name="hero-wrench-screwdriver"
|
||||
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t("No maintenance windows")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Schedule maintenance to suppress alerts during planned work.")}
|
||||
</p>
|
||||
|
|
@ -73,11 +82,21 @@
|
|||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Name")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Scope")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Time Range")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Status")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Created By")}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Scope")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Time Range")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Status")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Created By")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
|
|
@ -93,15 +112,21 @@
|
|||
{scope_label(window)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
{format_datetime(window.starts_at, @timezone)} — {format_datetime(window.ends_at, @timezone)}
|
||||
{format_datetime(window.starts_at, @timezone)} — {format_datetime(
|
||||
window.ends_at,
|
||||
@timezone
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% status = status_for(window) %>
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
status == :active && "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
|
||||
status == :upcoming && "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
status == :past && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
status == :active &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
|
||||
status == :upcoming &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
status == :past &&
|
||||
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
]}>
|
||||
{status |> to_string() |> String.capitalize()}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
defmodule ToweropsWeb.MaintenanceLive.Show do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Maintenance
|
||||
|
|
@ -33,8 +34,8 @@ defmodule ToweropsWeb.MaintenanceLive.Show do
|
|||
now = DateTime.utc_now()
|
||||
|
||||
cond do
|
||||
DateTime.compare(window.starts_at, now) == :gt -> :upcoming
|
||||
DateTime.compare(window.ends_at, now) == :lt -> :past
|
||||
DateTime.after?(window.starts_at, now) -> :upcoming
|
||||
DateTime.before?(window.ends_at, now) -> :past
|
||||
true -> :active
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,8 +21,12 @@
|
|||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-wrench-screwdriver" class="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-green-800 dark:text-green-300">{t("Currently Active")}</h3>
|
||||
<p class="text-sm text-green-700 dark:text-green-400">{t("This maintenance window is currently in effect. Alerts are being suppressed.")}</p>
|
||||
<h3 class="text-sm font-semibold text-green-800 dark:text-green-300">
|
||||
{t("Currently Active")}
|
||||
</h3>
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
{t("This maintenance window is currently in effect. Alerts are being suppressed.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -33,7 +37,8 @@
|
|||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@window.name}</h1>
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium mt-2",
|
||||
status == :active && "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
|
||||
status == :active &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
|
||||
status == :upcoming && "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
status == :past && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
]}>
|
||||
|
|
@ -64,28 +69,41 @@
|
|||
<%!-- Reason --%>
|
||||
<%= if @window.reason do %>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Reason")}</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">{@window.reason}</dd>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("Reason")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{@window.reason}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Scope --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Scope")}</dt>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("Scope")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">{scope_label(@window)}</dd>
|
||||
</div>
|
||||
|
||||
<%!-- Time Range --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Time Range")}</dt>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("Time Range")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{format_datetime(@window.starts_at, @timezone)} — {format_datetime(@window.ends_at, @timezone)}
|
||||
{format_datetime(@window.starts_at, @timezone)} — {format_datetime(
|
||||
@window.ends_at,
|
||||
@timezone
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%!-- Recurring --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Recurring")}</dt>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("Recurring")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<%= if @window.recurring do %>
|
||||
{t("Yes")} — {@window.recurrence_rule || t("No rule specified")}
|
||||
|
|
@ -97,7 +115,9 @@
|
|||
|
||||
<%!-- Suppress Alerts --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Suppress Alerts")}</dt>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("Suppress Alerts")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<%= if @window.suppress_alerts do %>
|
||||
<span class="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
|
||||
|
|
@ -113,7 +133,9 @@
|
|||
|
||||
<%!-- Created By --%>
|
||||
<div class="px-6 py-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Created By")}</dt>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("Created By")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{if @window.created_by, do: @window.created_by.email, else: "—"}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
@steps [:snmp, :site, :billing, :agent]
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
organization = socket.assigns.current_scope.organization
|
||||
changeset = Organizations.change_organization(organization)
|
||||
|
||||
site_changeset = Sites.change_site(%Towerops.Sites.Site{}, %{})
|
||||
site_changeset = Sites.change_site(%Site{}, %{})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -76,7 +77,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
@impl true
|
||||
def handle_event("validate_site", %{"site" => params}, socket) do
|
||||
changeset =
|
||||
%Towerops.Sites.Site{}
|
||||
%Site{}
|
||||
|> Sites.change_site(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
|
|
@ -91,7 +92,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
case Sites.create_site(attrs) do
|
||||
{:ok, _site} ->
|
||||
# Enable sites on the org if not already
|
||||
unless org.use_sites do
|
||||
if !org.use_sites do
|
||||
Organizations.update_organization(org, %{use_sites: true})
|
||||
end
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
def handle_event("save_integration", %{"integration" => params}, socket) do
|
||||
org = socket.assigns.organization
|
||||
provider = socket.assigns.selected_provider
|
||||
attrs = normalize_integration_params(params, provider) |> Map.put(:provider, provider) |> Map.put(:enabled, true)
|
||||
attrs = params |> normalize_integration_params(provider) |> Map.put(:provider, provider) |> Map.put(:enabled, true)
|
||||
|
||||
case Integrations.create_integration(org.id, attrs) do
|
||||
{:ok, _integration} ->
|
||||
|
|
@ -231,18 +232,22 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
end
|
||||
|
||||
defp normalize_integration_params(params, "sonar") do
|
||||
%{credentials: %{
|
||||
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
||||
"api_token" => Map.get(params, "api_token", "")
|
||||
}}
|
||||
%{
|
||||
credentials: %{
|
||||
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
||||
"api_token" => Map.get(params, "api_token", "")
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_integration_params(params, "splynx") do
|
||||
%{credentials: %{
|
||||
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
||||
"api_key" => Map.get(params, "api_key", ""),
|
||||
"api_secret" => Map.get(params, "api_secret", "")
|
||||
}}
|
||||
%{
|
||||
credentials: %{
|
||||
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
||||
"api_key" => Map.get(params, "api_key", ""),
|
||||
"api_secret" => Map.get(params, "api_secret", "")
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_integration_params(params, _provider) do
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@
|
|||
"flex items-center gap-1.5 text-sm font-medium px-2 py-1 rounded transition",
|
||||
if(step == @step,
|
||||
do: "text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30",
|
||||
else: if(step_index(step) < step_index(@step),
|
||||
do: "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white",
|
||||
else: "text-gray-400 dark:text-gray-500"
|
||||
)
|
||||
else:
|
||||
if(step_index(step) < step_index(@step),
|
||||
do:
|
||||
"text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white",
|
||||
else: "text-gray-400 dark:text-gray-500"
|
||||
)
|
||||
)
|
||||
]}
|
||||
>
|
||||
|
|
@ -26,10 +28,11 @@
|
|||
"flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold",
|
||||
if(step == @step,
|
||||
do: "bg-indigo-600 text-white dark:bg-indigo-500",
|
||||
else: if(step_index(step) < step_index(@step),
|
||||
do: "bg-gray-400 dark:bg-gray-500 text-white",
|
||||
else: "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
|
||||
)
|
||||
else:
|
||||
if(step_index(step) < step_index(@step),
|
||||
do: "bg-gray-400 dark:bg-gray-500 text-white",
|
||||
else: "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
|
||||
)
|
||||
)
|
||||
]}>
|
||||
<%= if step_index(step) < step_index(@step) do %>
|
||||
|
|
@ -41,7 +44,10 @@
|
|||
{step_label(step)}
|
||||
</button>
|
||||
<%= if step != List.last(@steps) do %>
|
||||
<.icon name="hero-chevron-right-mini" class="h-4 w-4 text-gray-300 dark:text-gray-600" />
|
||||
<.icon
|
||||
name="hero-chevron-right-mini"
|
||||
class="h-4 w-4 text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
|
@ -126,7 +132,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<% :site -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Add Your First Site")}
|
||||
|
|
@ -137,11 +142,34 @@
|
|||
|
||||
<.form for={@site_form} phx-change="validate_site" phx-submit="save_site">
|
||||
<div class="space-y-4">
|
||||
<.input field={@site_form[:name]} type="text" label={t("Name")} placeholder="e.g. Main Tower" required />
|
||||
<.input field={@site_form[:address]} type="text" label={t("Address")} placeholder="123 Tower Rd" />
|
||||
<.input
|
||||
field={@site_form[:name]}
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder="e.g. Main Tower"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@site_form[:address]}
|
||||
type="text"
|
||||
label={t("Address")}
|
||||
placeholder="123 Tower Rd"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<.input field={@site_form[:latitude]} type="number" label={t("Latitude")} step="any" placeholder="38.8977" />
|
||||
<.input field={@site_form[:longitude]} type="number" label={t("Longitude")} step="any" placeholder="-77.0365" />
|
||||
<.input
|
||||
field={@site_form[:latitude]}
|
||||
type="number"
|
||||
label={t("Latitude")}
|
||||
step="any"
|
||||
placeholder="38.8977"
|
||||
/>
|
||||
<.input
|
||||
field={@site_form[:longitude]}
|
||||
type="number"
|
||||
label={t("Longitude")}
|
||||
step="any"
|
||||
placeholder="-77.0365"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
|
|
@ -161,13 +189,14 @@
|
|||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<% :billing -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Connect a Billing Platform")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("Optional. Sync subscriber data for outage impact analysis and inventory reconciliation.")}
|
||||
{t(
|
||||
"Optional. Sync subscriber data for outage impact analysis and inventory reconciliation."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<%= if @selected_provider == nil do %>
|
||||
|
|
@ -180,7 +209,9 @@
|
|||
class="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-white/10 hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-gray-50 dark:hover:bg-white/5 transition text-left"
|
||||
>
|
||||
<.icon name={provider.icon} class="h-6 w-6 text-gray-400 dark:text-gray-500" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{provider.name}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{provider.name}
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -196,18 +227,49 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<.form for={@integration_form} phx-change="validate_integration" phx-submit="save_integration">
|
||||
<.form
|
||||
for={@integration_form}
|
||||
phx-change="validate_integration"
|
||||
phx-submit="save_integration"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<%= case @selected_provider do %>
|
||||
<% "sonar" -> %>
|
||||
<.input name="integration[instance_url]" type="text" label={t("Instance URL")} placeholder="https://myisp.sonar.software" value="" />
|
||||
<.input name="integration[api_token]" type="password" label={t("API Token")} value="" />
|
||||
<.input
|
||||
name="integration[instance_url]"
|
||||
type="text"
|
||||
label={t("Instance URL")}
|
||||
placeholder="https://myisp.sonar.software"
|
||||
value=""
|
||||
/>
|
||||
<.input
|
||||
name="integration[api_token]"
|
||||
type="password"
|
||||
label={t("API Token")}
|
||||
value=""
|
||||
/>
|
||||
<% "splynx" -> %>
|
||||
<.input name="integration[instance_url]" type="text" label={t("Instance URL")} placeholder="https://myisp.splynx.app" value="" />
|
||||
<.input
|
||||
name="integration[instance_url]"
|
||||
type="text"
|
||||
label={t("Instance URL")}
|
||||
placeholder="https://myisp.splynx.app"
|
||||
value=""
|
||||
/>
|
||||
<.input name="integration[api_key]" type="text" label={t("API Key")} value="" />
|
||||
<.input name="integration[api_secret]" type="password" label={t("API Secret")} value="" />
|
||||
<.input
|
||||
name="integration[api_secret]"
|
||||
type="password"
|
||||
label={t("API Secret")}
|
||||
value=""
|
||||
/>
|
||||
<% _ -> %>
|
||||
<.input name="integration[api_key]" type="password" label={t("API Key")} value="" />
|
||||
<.input
|
||||
name="integration[api_key]"
|
||||
type="password"
|
||||
label={t("API Key")}
|
||||
value=""
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
|
|
@ -240,13 +302,14 @@
|
|||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% :agent -> %>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Deploy Agent")}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t("The TowerOps agent polls your network devices via SNMP. Deploy it on a host with network access to your infrastructure.")}
|
||||
{t(
|
||||
"The TowerOps agent polls your network devices via SNMP. Deploy it on a host with network access to your infrastructure."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<%= if @agent_token do %>
|
||||
|
|
@ -264,13 +327,18 @@
|
|||
<% else %>
|
||||
<div class="rounded-md bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 p-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("Agent:")} <span class="font-medium text-gray-900 dark:text-white">{@agent_token.name}</span>
|
||||
{t("Agent:")}
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{@agent_token.name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{t("Docker")}</h3>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t("Docker")}
|
||||
</h3>
|
||||
<pre class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-4 overflow-x-auto select-all"><code>docker run -d \
|
||||
--name towerops-agent \
|
||||
--restart unless-stopped \
|
||||
|
|
@ -281,7 +349,9 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{t("Systemd")}</h3>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t("Systemd")}
|
||||
</h3>
|
||||
<pre class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-4 overflow-x-auto select-all"><code>curl -sSL https://install.towerops.net | \
|
||||
TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "<your-token>" %> \
|
||||
TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@
|
|||
<div class="rounded-lg border-2 border-indigo-200 bg-white p-6 dark:border-indigo-800/50 dark:bg-white/5">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
<.icon name="hero-arrow-path" class="h-3.5 w-3.5" />
|
||||
Choose one
|
||||
<.icon name="hero-arrow-path" class="h-3.5 w-3.5" /> Choose one
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
— only one billing platform can be active at a time
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -216,7 +216,10 @@
|
|||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{@trace.access_point.name}
|
||||
</div>
|
||||
<div :if={@trace.access_point.model} class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
:if={@trace.access_point.model}
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{@trace.access_point.model}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -362,7 +365,9 @@
|
|||
>
|
||||
<span class="text-gray-700 dark:text-gray-300 truncate">{ap.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{ap.subscriber_count || 0} subs</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{ap.subscriber_count || 0} subs
|
||||
</span>
|
||||
<span
|
||||
:if={ap.qoe_score}
|
||||
class={["font-medium", score_color(ap.qoe_score)]}
|
||||
|
|
|
|||
|
|
@ -748,6 +748,7 @@ defmodule ToweropsWeb.UserAuth do
|
|||
|
||||
mem ->
|
||||
alias Towerops.Organizations.Policy
|
||||
|
||||
scope = socket.assigns.current_scope
|
||||
|
||||
{:cont,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,20 @@ defmodule Towerops.Repo.Migrations.CreateMaintenanceWindows do
|
|||
def change do
|
||||
create table(:maintenance_windows, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :site_id, references(:sites, type: :binary_id, on_delete: :delete_all)
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all)
|
||||
add :name, :string, null: false
|
||||
add :reason, :string
|
||||
add :starts_at, :utc_datetime, null: false
|
||||
add :ends_at, :utc_datetime, null: false
|
||||
add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
|
||||
|
||||
add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all),
|
||||
null: false
|
||||
|
||||
add :recurring, :boolean, default: false
|
||||
add :recurrence_rule, :string
|
||||
add :suppress_alerts, :boolean, default: true
|
||||
|
|
@ -25,6 +31,8 @@ defmodule Towerops.Repo.Migrations.CreateMaintenanceWindows do
|
|||
create index(:maintenance_windows, [:starts_at])
|
||||
create index(:maintenance_windows, [:ends_at])
|
||||
|
||||
create constraint(:maintenance_windows, :ends_at_after_starts_at, check: "ends_at > starts_at")
|
||||
create constraint(:maintenance_windows, :ends_at_after_starts_at,
|
||||
check: "ends_at > starts_at"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,10 +4,18 @@ defmodule Towerops.Repo.Migrations.CreateDeviceSubscriberLinks do
|
|||
def change do
|
||||
create table(:device_subscriber_links, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :gaiia_account_id, references(:gaiia_accounts, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :gaiia_inventory_item_id, references(:gaiia_inventory_items, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
add :gaiia_account_id,
|
||||
references(:gaiia_accounts, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :gaiia_inventory_item_id,
|
||||
references(:gaiia_inventory_items, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
add :match_method, :string, null: false
|
||||
add :confidence, :string, null: false
|
||||
add :subscriber_ip, :string
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ defmodule Towerops.Repo.Migrations.CreateWirelessClients do
|
|||
create table(:wireless_clients, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :mac_address, :string, null: false
|
||||
add :ip_address, :string
|
||||
add :hostname, :string
|
||||
|
|
|
|||
|
|
@ -147,18 +147,19 @@ defmodule Towerops.Splynx.SyncTest do
|
|||
|> Req.Test.json(auth_success_response())
|
||||
|
||||
path ->
|
||||
data =
|
||||
cond do
|
||||
String.contains?(path, "/customer") -> Map.get(routes, "customer", [])
|
||||
String.contains?(path, "/routers") -> Map.get(routes, "routers", [])
|
||||
true -> []
|
||||
end
|
||||
|
||||
Req.Test.json(conn, data)
|
||||
Req.Test.json(conn, route_data(path, routes))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp route_data(path, routes) do
|
||||
cond do
|
||||
String.contains?(path, "/customer") -> Map.get(routes, "customer", [])
|
||||
String.contains?(path, "/routers") -> Map.get(routes, "routers", [])
|
||||
true -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp auth_success_response do
|
||||
%{
|
||||
"access_token" => "test_jwt_token",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
|
|||
alias Towerops.Devices
|
||||
alias Towerops.Monitoring.PingMock
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Workers.DeviceMonitorWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
|
@ -162,4 +163,124 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
|
|||
assert job.args["device_id"] == device.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "job uniqueness" do
|
||||
test "only one job per device exists at a time", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Unique Test",
|
||||
ip_address: "192.168.1.200",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
monitoring_enabled: true
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert two jobs for the same device
|
||||
{:ok, _job1} = DeviceMonitorWorker.start_monitoring(device.id)
|
||||
{:ok, _job2} = DeviceMonitorWorker.start_monitoring(device.id)
|
||||
|
||||
# Should only have one job in the database
|
||||
job_count =
|
||||
Oban.Job
|
||||
|> Ecto.Query.where(worker: "Towerops.Workers.DeviceMonitorWorker")
|
||||
|> Repo.aggregate(:count)
|
||||
|
||||
assert job_count == 1
|
||||
end
|
||||
|
||||
test "new job replaces scheduled_at of existing job", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Replace Test",
|
||||
ip_address: "192.168.1.201",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
monitoring_enabled: true
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert first job scheduled far in the future
|
||||
{:ok, job1} =
|
||||
%{device_id: device.id}
|
||||
|> DeviceMonitorWorker.new(schedule_in: 3600)
|
||||
|> Oban.insert()
|
||||
|
||||
original_scheduled_at = job1.scheduled_at
|
||||
|
||||
# Insert second job scheduled sooner
|
||||
{:ok, _job2} =
|
||||
%{device_id: device.id}
|
||||
|> DeviceMonitorWorker.new(schedule_in: 60)
|
||||
|> Oban.insert()
|
||||
|
||||
# Reload the job from DB - should have updated scheduled_at
|
||||
updated_job = Repo.get!(Oban.Job, job1.id)
|
||||
assert DateTime.before?(updated_job.scheduled_at, original_scheduled_at)
|
||||
end
|
||||
|
||||
test "self-scheduling works while job is executing", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Executing Test",
|
||||
ip_address: "192.168.1.202",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
monitoring_enabled: true
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert a job and manually set it to executing state
|
||||
{:ok, job1} =
|
||||
%{device_id: device.id}
|
||||
|> DeviceMonitorWorker.new()
|
||||
|> Oban.insert()
|
||||
|
||||
# Transition the job to executing state
|
||||
Repo.update_all(
|
||||
Ecto.Query.where(Oban.Job, id: ^job1.id),
|
||||
set: [state: "executing"]
|
||||
)
|
||||
|
||||
# Insert another job for the same device (simulating self-scheduling)
|
||||
{:ok, job2} =
|
||||
%{device_id: device.id}
|
||||
|> DeviceMonitorWorker.new(schedule_in: 60)
|
||||
|> Oban.insert()
|
||||
|
||||
# Should succeed - the new job should be a different job
|
||||
assert job2.id != job1.id
|
||||
|
||||
# Should have 2 jobs: one executing, one scheduled
|
||||
job_count =
|
||||
Oban.Job
|
||||
|> Ecto.Query.where(worker: "Towerops.Workers.DeviceMonitorWorker")
|
||||
|> Repo.aggregate(:count)
|
||||
|
||||
assert job_count == 2
|
||||
end
|
||||
|
||||
test "max_attempts is 1", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Max Attempts Test",
|
||||
ip_address: "192.168.1.203",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
monitoring_enabled: true
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
{:ok, job} = DeviceMonitorWorker.start_monitoring(device.id)
|
||||
assert job.max_attempts == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
alias Towerops.Workers.DevicePollerWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
@moduletag :skip
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
|
|
@ -58,6 +56,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
end
|
||||
|
||||
describe "perform/1" do
|
||||
@describetag :skip
|
||||
|
||||
test "returns :ok when device does not exist" do
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => Ecto.UUID.generate()}})
|
||||
end
|
||||
|
|
@ -105,6 +105,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
end
|
||||
|
||||
describe "polling logic" do
|
||||
@describetag :skip
|
||||
|
||||
test "polls sensors and updates values", %{site: site} do
|
||||
# 1. Create Device
|
||||
{:ok, device} =
|
||||
|
|
@ -282,6 +284,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
end
|
||||
|
||||
describe "start_polling/1" do
|
||||
@describetag :skip
|
||||
|
||||
test "schedules initial job with offset", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
|
|
@ -330,6 +334,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
end
|
||||
|
||||
describe "stop_polling/1" do
|
||||
@describetag :skip
|
||||
|
||||
test "cancels all jobs for device", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
|
|
@ -349,6 +355,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
end
|
||||
|
||||
describe "race condition handling" do
|
||||
@describetag :skip
|
||||
|
||||
test "handles device deletion during poll gracefully", %{organization: org, site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
|
|
@ -485,4 +493,127 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end
|
||||
end
|
||||
|
||||
describe "job uniqueness" do
|
||||
test "only one job per device exists at a time", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Unique Test",
|
||||
ip_address: "192.168.1.200",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert two jobs for the same device
|
||||
{:ok, _job1} = DevicePollerWorker.start_polling(device.id)
|
||||
{:ok, _job2} = DevicePollerWorker.start_polling(device.id)
|
||||
|
||||
# Should only have one job in the database
|
||||
job_count =
|
||||
Oban.Job
|
||||
|> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker")
|
||||
|> Repo.aggregate(:count)
|
||||
|
||||
assert job_count == 1
|
||||
end
|
||||
|
||||
test "new job replaces scheduled_at of existing job", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Replace Test",
|
||||
ip_address: "192.168.1.201",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert first job scheduled far in the future
|
||||
{:ok, job1} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new(schedule_in: 3600)
|
||||
|> Oban.insert()
|
||||
|
||||
original_scheduled_at = job1.scheduled_at
|
||||
|
||||
# Insert second job scheduled sooner
|
||||
{:ok, _job2} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new(schedule_in: 60)
|
||||
|> Oban.insert()
|
||||
|
||||
# Reload the job from DB - should have updated scheduled_at
|
||||
updated_job = Repo.get!(Oban.Job, job1.id)
|
||||
assert DateTime.before?(updated_job.scheduled_at, original_scheduled_at)
|
||||
end
|
||||
|
||||
test "self-scheduling works while job is executing", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Executing Test",
|
||||
ip_address: "192.168.1.202",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
check_interval_seconds: 300
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
# Insert a job and manually set it to executing state
|
||||
{:ok, job1} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new()
|
||||
|> Oban.insert()
|
||||
|
||||
# Transition the job to executing state
|
||||
Repo.update_all(
|
||||
Ecto.Query.where(Oban.Job, id: ^job1.id),
|
||||
set: [state: "executing"]
|
||||
)
|
||||
|
||||
# Insert another job for the same device (simulating self-scheduling)
|
||||
{:ok, job2} =
|
||||
%{device_id: device.id}
|
||||
|> DevicePollerWorker.new(schedule_in: 300)
|
||||
|> Oban.insert()
|
||||
|
||||
# Should succeed - the new job should be a different job
|
||||
assert job2.id != job1.id
|
||||
|
||||
# Should have 2 jobs: one executing, one scheduled
|
||||
job_count =
|
||||
Oban.Job
|
||||
|> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker")
|
||||
|> Repo.aggregate(:count)
|
||||
|
||||
assert job_count == 2
|
||||
end
|
||||
|
||||
test "max_attempts is 1", %{site: site} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Max Attempts Test",
|
||||
ip_address: "192.168.1.203",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
# Clear jobs created by create_device
|
||||
Repo.delete_all(Oban.Job)
|
||||
|
||||
{:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
assert job.max_attempts == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue