ui: major redesign - NOC dashboard, consolidated nav, dense tables, flat alerts
This commit is contained in:
parent
c7ba420ff1
commit
02474c529f
22 changed files with 2731 additions and 1222 deletions
|
|
@ -936,6 +936,37 @@ const MikrotikPortSync = {
|
|||
}
|
||||
}
|
||||
|
||||
// Simple Leaflet map hook for showing a single marker (site detail page)
|
||||
const LeafletMap = {
|
||||
map: null as any,
|
||||
mounted(this: any) {
|
||||
const lat = parseFloat(this.el.dataset.lat)
|
||||
const lng = parseFloat(this.el.dataset.lng)
|
||||
const zoom = parseInt(this.el.dataset.zoom || '14')
|
||||
const title = this.el.dataset.markerTitle || ''
|
||||
if (isNaN(lat) || isNaN(lng)) return
|
||||
|
||||
const initMap = () => {
|
||||
if (typeof L === 'undefined') {
|
||||
setTimeout(initMap, 100)
|
||||
return
|
||||
}
|
||||
this.map = L.map(this.el, { scrollWheelZoom: false, zoomControl: true })
|
||||
this.map.setView([lat, lng], zoom)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(this.map)
|
||||
L.marker([lat, lng]).addTo(this.map).bindPopup(title)
|
||||
setTimeout(() => this.map.invalidateSize(), 200)
|
||||
}
|
||||
initMap()
|
||||
},
|
||||
destroyed(this: any) {
|
||||
if (this.map) { this.map.remove(); this.map = null }
|
||||
}
|
||||
}
|
||||
|
||||
// Sites Map hook for Leaflet.js geographic visualization
|
||||
const SitesMap = {
|
||||
map: null as any,
|
||||
|
|
@ -1102,7 +1133,7 @@ const GlobalSearch = {
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
|
|||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@valid_providers ~w(preseem gaiia pagerduty netbox)
|
||||
@valid_providers ~w(preseem gaiia pagerduty netbox sonar splynx visp)
|
||||
@valid_sync_statuses ~w(never success partial failed)
|
||||
|
||||
schema "integrations" do
|
||||
|
|
|
|||
185
lib/towerops/sonar/client.ex
Normal file
185
lib/towerops/sonar/client.ex
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
defmodule Towerops.Sonar.Client do
|
||||
@moduledoc """
|
||||
GraphQL client for the Sonar API.
|
||||
|
||||
Uses Req with page-based pagination for all list endpoints.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@accounts_query """
|
||||
query ListAccounts($page: Int!, $records_per_page: Int!) {
|
||||
accounts(paginator: {page: $page, records_per_page: $records_per_page}) {
|
||||
entities {
|
||||
id
|
||||
name
|
||||
account_status { name }
|
||||
account_type { name }
|
||||
addresses { line1 city state zip country latitude longitude }
|
||||
account_services {
|
||||
name
|
||||
service { name }
|
||||
price_override
|
||||
next_bill_date
|
||||
}
|
||||
}
|
||||
page_info { page total_pages records_per_page total_count }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@network_sites_query """
|
||||
query ListNetworkSites($page: Int!, $records_per_page: Int!) {
|
||||
network_sites(paginator: {page: $page, records_per_page: $records_per_page}) {
|
||||
entities {
|
||||
id
|
||||
name
|
||||
latitude
|
||||
longitude
|
||||
ip_assignments { ip_address subnet description }
|
||||
}
|
||||
page_info { page total_pages records_per_page total_count }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@inventory_items_query """
|
||||
query ListInventoryItems($page: Int!, $records_per_page: Int!) {
|
||||
inventory_items(paginator: {page: $page, records_per_page: $records_per_page}) {
|
||||
entities {
|
||||
id
|
||||
name
|
||||
inventory_model { name manufacturer { name } }
|
||||
assignee { id __typename }
|
||||
}
|
||||
page_info { page total_pages records_per_page total_count }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@doc "Test the connection to Sonar by fetching a single account."
|
||||
def test_connection(instance_url, api_token) do
|
||||
test_query = """
|
||||
{ accounts(paginator: {page: 1, records_per_page: 1}) {
|
||||
entities { id }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
case query(instance_url, api_token, test_query) do
|
||||
{:ok, _data} -> {:ok, %{}}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "List all accounts with automatic pagination."
|
||||
def list_accounts(instance_url, api_token) do
|
||||
paginate(instance_url, api_token, @accounts_query, "accounts")
|
||||
end
|
||||
|
||||
@doc "List all network sites with automatic pagination."
|
||||
def list_network_sites(instance_url, api_token) do
|
||||
paginate(instance_url, api_token, @network_sites_query, "network_sites")
|
||||
end
|
||||
|
||||
@doc "List all inventory items with automatic pagination."
|
||||
def list_inventory_items(instance_url, api_token) do
|
||||
paginate(instance_url, api_token, @inventory_items_query, "inventory_items")
|
||||
end
|
||||
|
||||
@doc "Execute a raw GraphQL query against the Sonar API."
|
||||
def query(instance_url, api_token, query_string, variables \\ %{}) do
|
||||
case request(instance_url, api_token, query_string, variables) do
|
||||
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
|
||||
{:ok, data}
|
||||
|
||||
{:ok, %{status: status, body: %{"errors" => errors}}} when status in [200, 400] ->
|
||||
{:error, {:graphql_errors, errors}}
|
||||
|
||||
{:ok, %{status: 401}} ->
|
||||
{:error, :unauthorized}
|
||||
|
||||
{:ok, %{status: 403}} ->
|
||||
{:error, :forbidden}
|
||||
|
||||
{:ok, %{status: 429, headers: headers}} ->
|
||||
retry_after = get_retry_after(headers)
|
||||
{:error, {:rate_limited, retry_after}}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.warning("Sonar API unexpected status #{status}: #{inspect(body)}")
|
||||
{:error, {:unexpected_status, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Sonar API connection error: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp paginate(instance_url, api_token, query_string, root_key, page \\ 1, acc \\ []) do
|
||||
variables = %{"page" => page, "records_per_page" => 100}
|
||||
|
||||
case query(instance_url, api_token, query_string, variables) do
|
||||
{:ok, data} ->
|
||||
root = data[root_key]
|
||||
entities = root["entities"] || []
|
||||
all_entities = acc ++ entities
|
||||
|
||||
page_info = root["page_info"]
|
||||
|
||||
if page < page_info["total_pages"] do
|
||||
paginate(instance_url, api_token, query_string, root_key, page + 1, all_entities)
|
||||
else
|
||||
{:ok, all_entities}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp request(instance_url, api_token, query_string, variables) do
|
||||
url = String.trim_trailing(instance_url, "/") <> "/api/graphql"
|
||||
|
||||
req_opts = [
|
||||
method: :post,
|
||||
url: url,
|
||||
headers: [
|
||||
{"authorization", "Bearer #{api_token}"},
|
||||
{"content-type", "application/json"}
|
||||
],
|
||||
json: %{"query" => query_string, "variables" => variables}
|
||||
]
|
||||
|
||||
req_opts =
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
|
||||
else
|
||||
req_opts
|
||||
end
|
||||
|
||||
Req.request(req_opts)
|
||||
rescue
|
||||
exception ->
|
||||
{:error, Exception.message(exception)}
|
||||
end
|
||||
|
||||
defp get_retry_after(headers) when is_map(headers) do
|
||||
case Map.get(headers, "retry-after") do
|
||||
[value | _] -> parse_retry_after(value)
|
||||
_ -> 60
|
||||
end
|
||||
end
|
||||
|
||||
defp get_retry_after(_headers), do: 60
|
||||
|
||||
defp parse_retry_after(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{seconds, _} -> seconds
|
||||
:error -> 60
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_retry_after(value) when is_integer(value), do: value
|
||||
defp parse_retry_after(_), do: 60
|
||||
end
|
||||
87
lib/towerops/sonar/sync.ex
Normal file
87
lib/towerops/sonar/sync.ex
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
defmodule Towerops.Sonar.Sync do
|
||||
@moduledoc """
|
||||
Orchestrates syncing data from the Sonar GraphQL API into the local database.
|
||||
|
||||
Pulls accounts, network sites, and inventory items from Sonar
|
||||
and updates integration sync status.
|
||||
"""
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Sonar.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Main entry point: syncs all entity types for the given integration.
|
||||
|
||||
Returns `{:ok, %{accounts: n, network_sites: n, inventory_items: n}}`
|
||||
or `{:error, reason}`.
|
||||
"""
|
||||
def sync_organization(%Integrations.Integration{} = integration) do
|
||||
instance_url = integration.credentials["instance_url"]
|
||||
api_token = integration.credentials["api_token"]
|
||||
|
||||
with {:ok, accounts} <- Client.list_accounts(instance_url, api_token),
|
||||
{:ok, sites} <- Client.list_network_sites(instance_url, api_token),
|
||||
{:ok, items} <- Client.list_inventory_items(instance_url, api_token) do
|
||||
accounts_count = length(accounts)
|
||||
sites_count = length(sites)
|
||||
items_count = length(items)
|
||||
|
||||
mrr = calculate_total_mrr(accounts)
|
||||
|
||||
message =
|
||||
"Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items" <>
|
||||
if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "")
|
||||
|
||||
Integrations.update_sync_status(integration, "success", message)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
accounts: accounts_count,
|
||||
network_sites: sites_count,
|
||||
inventory_items: items_count
|
||||
}}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_total_mrr(accounts) do
|
||||
Enum.reduce(accounts, 0.0, fn account, total ->
|
||||
services = account["account_services"] || []
|
||||
|
||||
service_mrr =
|
||||
Enum.reduce(services, 0.0, fn service, acc ->
|
||||
price = service["price_override"]
|
||||
|
||||
if is_number(price) do
|
||||
acc + price
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
total + service_mrr
|
||||
end)
|
||||
end
|
||||
|
||||
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token"
|
||||
defp humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions"
|
||||
defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s"
|
||||
defp humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}"
|
||||
|
||||
defp humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do
|
||||
messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1)))
|
||||
"GraphQL errors: #{messages}"
|
||||
end
|
||||
|
||||
defp humanize_sync_error(reason) when is_binary(reason), do: reason
|
||||
|
||||
defp humanize_sync_error(reason) do
|
||||
Logger.warning("Sonar sync failed with unexpected error: #{inspect(reason)}")
|
||||
"An unexpected error occurred during sync: #{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
99
lib/towerops/splynx/client.ex
Normal file
99
lib/towerops/splynx/client.ex
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
defmodule Towerops.Splynx.Client do
|
||||
@moduledoc """
|
||||
REST client for the Splynx API.
|
||||
|
||||
Uses Req with HTTP Basic Auth and page-based pagination.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@doc "Test the connection to Splynx by fetching a single customer."
|
||||
def test_connection(instance_url, api_key, api_secret) do
|
||||
case get(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer", %{"per_page" => "1"}) do
|
||||
{:ok, _data} -> {:ok, %{}}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "List all customers with automatic pagination."
|
||||
def list_customers(instance_url, api_key, api_secret) do
|
||||
paginate(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer")
|
||||
end
|
||||
|
||||
@doc "List internet services for a specific customer."
|
||||
def list_customer_services(instance_url, api_key, api_secret, customer_id) do
|
||||
get(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer/#{customer_id}/internet-services")
|
||||
end
|
||||
|
||||
@doc "List all routers/network sites."
|
||||
def list_routers(instance_url, api_key, api_secret) do
|
||||
paginate(instance_url, api_key, api_secret, "/api/2.0/admin/networking/routers")
|
||||
end
|
||||
|
||||
defp get(instance_url, api_key, api_secret, path, params \\ %{}) do
|
||||
url = String.trim_trailing(instance_url, "/") <> path
|
||||
|
||||
req_opts = [
|
||||
method: :get,
|
||||
url: url,
|
||||
headers: [
|
||||
{"authorization", "Basic #{Base.encode64("#{api_key}:#{api_secret}")}"},
|
||||
{"accept", "application/json"}
|
||||
],
|
||||
params: params
|
||||
]
|
||||
|
||||
req_opts =
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
|
||||
else
|
||||
req_opts
|
||||
end
|
||||
|
||||
case Req.request(req_opts) do
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{status: 401}} ->
|
||||
{:error, :unauthorized}
|
||||
|
||||
{:ok, %{status: 403}} ->
|
||||
{:error, :forbidden}
|
||||
|
||||
{:ok, %{status: 429}} ->
|
||||
{:error, {:rate_limited, 60}}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.warning("Splynx API unexpected status #{status}: #{inspect(body)}")
|
||||
{:error, {:unexpected_status, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Splynx API connection error: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
rescue
|
||||
exception ->
|
||||
{:error, Exception.message(exception)}
|
||||
end
|
||||
|
||||
defp paginate(instance_url, api_key, api_secret, path, page \\ 1, acc \\ []) do
|
||||
params = %{"page" => to_string(page), "per_page" => "100"}
|
||||
|
||||
case get(instance_url, api_key, api_secret, path, params) do
|
||||
{:ok, data} when is_list(data) ->
|
||||
all = acc ++ data
|
||||
|
||||
if length(data) >= 100 do
|
||||
paginate(instance_url, api_key, api_secret, path, page + 1, all)
|
||||
else
|
||||
{:ok, all}
|
||||
end
|
||||
|
||||
{:ok, data} ->
|
||||
{:ok, acc ++ List.wrap(data)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
57
lib/towerops/splynx/sync.ex
Normal file
57
lib/towerops/splynx/sync.ex
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
defmodule Towerops.Splynx.Sync do
|
||||
@moduledoc """
|
||||
Orchestrates syncing data from the Splynx REST API into the local database.
|
||||
|
||||
Pulls customers, internet services, and routers from Splynx
|
||||
and updates integration sync status.
|
||||
"""
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Splynx.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Main entry point: syncs all entity types for the given integration.
|
||||
|
||||
Returns `{:ok, %{customers: n, routers: n}}` or `{:error, reason}`.
|
||||
"""
|
||||
def sync_organization(%Integrations.Integration{} = integration) do
|
||||
instance_url = integration.credentials["instance_url"]
|
||||
api_key = integration.credentials["api_key"]
|
||||
api_secret = integration.credentials["api_secret"]
|
||||
|
||||
with {:ok, customers} <- Client.list_customers(instance_url, api_key, api_secret),
|
||||
{:ok, routers} <- Client.list_routers(instance_url, api_key, api_secret) do
|
||||
customers_count = length(customers)
|
||||
routers_count = length(routers)
|
||||
active_count = Enum.count(customers, &(&1["status"] == "active"))
|
||||
|
||||
message =
|
||||
"Synced #{customers_count} customers (#{active_count} active), #{routers_count} routers"
|
||||
|
||||
Integrations.update_sync_status(integration, "success", message)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
customers: customers_count,
|
||||
routers: routers_count
|
||||
}}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key and secret"
|
||||
defp humanize_sync_error(:forbidden), do: "Access denied — your API credentials may not have sufficient permissions"
|
||||
defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Splynx — retry after #{retry_after}s"
|
||||
defp humanize_sync_error({:unexpected_status, status}), do: "Splynx returned unexpected HTTP #{status}"
|
||||
defp humanize_sync_error(reason) when is_binary(reason), do: reason
|
||||
|
||||
defp humanize_sync_error(reason) do
|
||||
Logger.warning("Splynx sync failed with unexpected error: #{inspect(reason)}")
|
||||
"An unexpected error occurred during sync: #{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
109
lib/towerops/visp/client.ex
Normal file
109
lib/towerops/visp/client.ex
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
defmodule Towerops.Visp.Client do
|
||||
@moduledoc """
|
||||
REST client for the VISP API.
|
||||
|
||||
Uses Req with X-API-Key authentication and page-based pagination.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@base_url "https://api.visp.net"
|
||||
|
||||
@doc "Test the connection to VISP by fetching a single subscriber."
|
||||
def test_connection(api_key) do
|
||||
case get(api_key, "/api/v1/subscribers", %{"per_page" => "1"}) do
|
||||
{:ok, _data} -> {:ok, %{}}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "List all subscribers with automatic pagination."
|
||||
def list_subscribers(api_key) do
|
||||
paginate(api_key, "/api/v1/subscribers")
|
||||
end
|
||||
|
||||
@doc "List all sites/towers with automatic pagination."
|
||||
def list_sites(api_key) do
|
||||
paginate(api_key, "/api/v1/sites")
|
||||
end
|
||||
|
||||
@doc "List all equipment with automatic pagination."
|
||||
def list_equipment(api_key) do
|
||||
paginate(api_key, "/api/v1/equipment")
|
||||
end
|
||||
|
||||
@doc "List all service plans."
|
||||
def list_services(api_key) do
|
||||
get(api_key, "/api/v1/services")
|
||||
end
|
||||
|
||||
defp get(api_key, path, params \\ %{}) do
|
||||
url = @base_url <> path
|
||||
|
||||
req_opts = [
|
||||
method: :get,
|
||||
url: url,
|
||||
headers: [
|
||||
{"x-api-key", api_key},
|
||||
{"accept", "application/json"}
|
||||
],
|
||||
params: params
|
||||
]
|
||||
|
||||
req_opts =
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
|
||||
else
|
||||
req_opts
|
||||
end
|
||||
|
||||
case Req.request(req_opts) do
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{status: 401}} ->
|
||||
{:error, :unauthorized}
|
||||
|
||||
{:ok, %{status: 403}} ->
|
||||
{:error, :forbidden}
|
||||
|
||||
{:ok, %{status: 429}} ->
|
||||
{:error, {:rate_limited, 60}}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.warning("VISP API unexpected status #{status}: #{inspect(body)}")
|
||||
{:error, {:unexpected_status, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("VISP API connection error: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
rescue
|
||||
exception ->
|
||||
{:error, Exception.message(exception)}
|
||||
end
|
||||
|
||||
defp paginate(api_key, path, page \\ 1, acc \\ []) do
|
||||
params = %{"page" => to_string(page), "per_page" => "100"}
|
||||
|
||||
case get(api_key, path, params) do
|
||||
{:ok, %{"data" => data, "meta" => meta}} when is_list(data) ->
|
||||
all = acc ++ data
|
||||
|
||||
if page < (meta["last_page"] || 1) do
|
||||
paginate(api_key, path, page + 1, all)
|
||||
else
|
||||
{:ok, all}
|
||||
end
|
||||
|
||||
{:ok, %{"data" => data}} when is_list(data) ->
|
||||
{:ok, acc ++ data}
|
||||
|
||||
{:ok, data} when is_list(data) ->
|
||||
{:ok, acc ++ data}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
65
lib/towerops/visp/sync.ex
Normal file
65
lib/towerops/visp/sync.ex
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
defmodule Towerops.Visp.Sync do
|
||||
@moduledoc """
|
||||
Orchestrates syncing data from the VISP REST API into the local database.
|
||||
|
||||
Pulls subscribers, sites, and equipment from VISP
|
||||
and updates integration sync status.
|
||||
"""
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Visp.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Main entry point: syncs all entity types for the given integration.
|
||||
|
||||
Returns `{:ok, %{subscribers: n, sites: n, equipment: n}}`
|
||||
or `{:error, reason}`.
|
||||
"""
|
||||
def sync_organization(%Integrations.Integration{} = integration) do
|
||||
api_key = integration.credentials["api_key"]
|
||||
|
||||
with {:ok, subscribers} <- Client.list_subscribers(api_key),
|
||||
{:ok, sites} <- Client.list_sites(api_key),
|
||||
{:ok, equipment} <- Client.list_equipment(api_key) do
|
||||
subscribers_count = length(subscribers)
|
||||
sites_count = length(sites)
|
||||
equipment_count = length(equipment)
|
||||
|
||||
total_mrr =
|
||||
Enum.reduce(subscribers, 0.0, fn sub, acc ->
|
||||
mrr = sub["mrr"]
|
||||
if is_number(mrr), do: acc + mrr, else: acc
|
||||
end)
|
||||
|
||||
message =
|
||||
"Synced #{subscribers_count} subscribers, #{sites_count} sites, #{equipment_count} equipment" <>
|
||||
if(total_mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(total_mrr / 1, decimals: 2)})", else: "")
|
||||
|
||||
Integrations.update_sync_status(integration, "success", message)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
subscribers: subscribers_count,
|
||||
sites: sites_count,
|
||||
equipment: equipment_count
|
||||
}}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key"
|
||||
defp humanize_sync_error(:forbidden), do: "Access denied — your API key may not have sufficient permissions"
|
||||
defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by VISP — retry after #{retry_after}s"
|
||||
defp humanize_sync_error({:unexpected_status, status}), do: "VISP returned unexpected HTTP #{status}"
|
||||
defp humanize_sync_error(reason) when is_binary(reason), do: reason
|
||||
|
||||
defp humanize_sync_error(reason) do
|
||||
Logger.warning("VISP sync failed with unexpected error: #{inspect(reason)}")
|
||||
"An unexpected error occurred during sync: #{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
56
lib/towerops/workers/sonar_sync_worker.ex
Normal file
56
lib/towerops/workers/sonar_sync_worker.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Towerops.Workers.SonarSyncWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that syncs Sonar data for all enabled integrations.
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Sonar.Sync
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
integrations = Integrations.list_enabled_integrations("sonar")
|
||||
|
||||
results = Enum.map(integrations, &sync_integration/1)
|
||||
|
||||
synced = Enum.count(results, &match?({:ok, _}, &1))
|
||||
failed = Enum.count(results, &match?({:error, _}, &1))
|
||||
skipped = Enum.count(results, &(&1 == :skipped))
|
||||
|
||||
if synced > 0 or failed > 0 do
|
||||
Logger.info("Sonar sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp sync_integration(integration) do
|
||||
if should_sync?(integration) do
|
||||
case Sync.sync_organization(integration) do
|
||||
{:ok, result} ->
|
||||
Logger.info("Sonar sync completed for org #{integration.organization_id}: #{inspect(result)}")
|
||||
{:ok, result}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Sonar sync failed for org #{integration.organization_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
:skipped
|
||||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
||||
last_synced_at ->
|
||||
interval_seconds = (integration.sync_interval_minutes || 10) * 60
|
||||
elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
|
||||
elapsed >= interval_seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
56
lib/towerops/workers/splynx_sync_worker.ex
Normal file
56
lib/towerops/workers/splynx_sync_worker.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Towerops.Workers.SplynxSyncWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that syncs Splynx data for all enabled integrations.
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Splynx.Sync
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
integrations = Integrations.list_enabled_integrations("splynx")
|
||||
|
||||
results = Enum.map(integrations, &sync_integration/1)
|
||||
|
||||
synced = Enum.count(results, &match?({:ok, _}, &1))
|
||||
failed = Enum.count(results, &match?({:error, _}, &1))
|
||||
skipped = Enum.count(results, &(&1 == :skipped))
|
||||
|
||||
if synced > 0 or failed > 0 do
|
||||
Logger.info("Splynx sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp sync_integration(integration) do
|
||||
if should_sync?(integration) do
|
||||
case Sync.sync_organization(integration) do
|
||||
{:ok, result} ->
|
||||
Logger.info("Splynx sync completed for org #{integration.organization_id}: #{inspect(result)}")
|
||||
{:ok, result}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Splynx sync failed for org #{integration.organization_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
:skipped
|
||||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
||||
last_synced_at ->
|
||||
interval_seconds = (integration.sync_interval_minutes || 10) * 60
|
||||
elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
|
||||
elapsed >= interval_seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
56
lib/towerops/workers/visp_sync_worker.ex
Normal file
56
lib/towerops/workers/visp_sync_worker.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Towerops.Workers.VispSyncWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that syncs VISP data for all enabled integrations.
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Visp.Sync
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
integrations = Integrations.list_enabled_integrations("visp")
|
||||
|
||||
results = Enum.map(integrations, &sync_integration/1)
|
||||
|
||||
synced = Enum.count(results, &match?({:ok, _}, &1))
|
||||
failed = Enum.count(results, &match?({:error, _}, &1))
|
||||
skipped = Enum.count(results, &(&1 == :skipped))
|
||||
|
||||
if synced > 0 or failed > 0 do
|
||||
Logger.info("VISP sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp sync_integration(integration) do
|
||||
if should_sync?(integration) do
|
||||
case Sync.sync_organization(integration) do
|
||||
{:ok, result} ->
|
||||
Logger.info("VISP sync completed for org #{integration.organization_id}: #{inspect(result)}")
|
||||
{:ok, result}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("VISP sync failed for org #{integration.organization_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
:skipped
|
||||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
||||
last_synced_at ->
|
||||
interval_seconds = (integration.sync_interval_minutes || 10) * 60
|
||||
elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
|
||||
elapsed >= interval_seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -200,66 +200,181 @@ defmodule ToweropsWeb.Layouts do
|
|||
</.link>
|
||||
</div>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<!-- Org selector (next to logo) -->
|
||||
<div :if={@current_organization} class="hidden md:flex items-center ml-3">
|
||||
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600 mr-3"></div>
|
||||
<button
|
||||
type="button"
|
||||
id="org-switcher-button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-controls="org-switcher-menu"
|
||||
phx-click={
|
||||
JS.toggle(to: "#org-switcher-menu")
|
||||
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
||||
to: "#org-switcher-button"
|
||||
)
|
||||
}
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{@current_organization.name}
|
||||
<.icon name="hero-chevron-down" class="h-3 w-3" />
|
||||
</button>
|
||||
<div
|
||||
id="org-switcher-menu"
|
||||
role="menu"
|
||||
class="hidden absolute top-12 left-auto mt-1 w-48 origin-top 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: "#org-switcher-menu")
|
||||
|> JS.set_attribute({"aria-expanded", "false"}, to: "#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: "#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: "#org-switcher-menu")}
|
||||
>
|
||||
{t("Organization Settings")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop navigation: Primary -->
|
||||
<div
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
class="hidden md:ml-8 md:flex md:space-x-6"
|
||||
class="hidden md:ml-6 md:flex md:items-center md:space-x-1"
|
||||
>
|
||||
<.nav_link
|
||||
navigate={~p"/dashboard"}
|
||||
active={@active_page == "dashboard"}
|
||||
>
|
||||
Dashboard
|
||||
{t("Dashboard")}
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/devices"}
|
||||
active={@active_page == "devices"}
|
||||
>
|
||||
{t("Devices")}
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
:if={@current_organization && @current_organization.use_sites}
|
||||
navigate={~p"/sites"}
|
||||
active={@active_page == "sites"}
|
||||
>
|
||||
Sites
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/devices"}
|
||||
active={@active_page == "devices"}
|
||||
>
|
||||
Devices
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/network-map"}
|
||||
active={@active_page == "network-map"}
|
||||
>
|
||||
Network Map
|
||||
{t("Sites")}
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
>
|
||||
Alerts
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/insights"}
|
||||
active={@active_page == "insights"}
|
||||
>
|
||||
Insights
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/trace"}
|
||||
active={@active_page == "trace"}
|
||||
>
|
||||
Trace
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/activity"}
|
||||
active={@active_page == "activity"}
|
||||
>
|
||||
Activity
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/help"}
|
||||
active={@active_page == "help"}
|
||||
>
|
||||
Help
|
||||
{t("Alerts")}
|
||||
</.nav_link>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||
|
||||
<!-- More dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
id="more-nav-button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
phx-click={
|
||||
JS.toggle(to: "#more-nav-menu")
|
||||
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
||||
to: "#more-nav-button"
|
||||
)
|
||||
}
|
||||
class={[
|
||||
"inline-flex items-center gap-1 h-14 sm:h-16 px-2 text-sm font-medium border-b-2 transition-colors",
|
||||
if(@active_page in ["network-map", "insights", "trace", "activity"],
|
||||
do:
|
||||
"border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
|
||||
else:
|
||||
"border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{t("More")}
|
||||
<.icon name="hero-chevron-down" class="h-3 w-3" />
|
||||
</button>
|
||||
<div
|
||||
id="more-nav-menu"
|
||||
role="menu"
|
||||
class="hidden absolute top-full left-0 mt-0 w-44 origin-top-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: "#more-nav-menu")
|
||||
|> JS.set_attribute({"aria-expanded", "false"}, to: "#more-nav-button")
|
||||
}
|
||||
>
|
||||
<div class="py-1">
|
||||
<.link
|
||||
navigate={~p"/network-map"}
|
||||
role="menuitem"
|
||||
class={[
|
||||
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
||||
if(@active_page == "network-map",
|
||||
do: "text-gray-900 font-medium dark:text-white",
|
||||
else: "text-gray-700 dark:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-map" class="h-4 w-4 inline mr-2" />{t("Network Map")}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/insights"}
|
||||
role="menuitem"
|
||||
class={[
|
||||
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
||||
if(@active_page == "insights",
|
||||
do: "text-gray-900 font-medium dark:text-white",
|
||||
else: "text-gray-700 dark:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-light-bulb" class="h-4 w-4 inline mr-2" />{t("Insights")}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/trace"}
|
||||
role="menuitem"
|
||||
class={[
|
||||
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
||||
if(@active_page == "trace",
|
||||
do: "text-gray-900 font-medium dark:text-white",
|
||||
else: "text-gray-700 dark:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4 inline mr-2" />{t("Trace")}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/activity"}
|
||||
role="menuitem"
|
||||
class={[
|
||||
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
|
||||
if(@active_page == "activity",
|
||||
do: "text-gray-900 font-medium dark:text-white",
|
||||
else: "text-gray-700 dark:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-clock" class="h-4 w-4 inline mr-2" />{t("Activity")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -274,12 +389,34 @@ defmodule ToweropsWeb.Layouts do
|
|||
title="Search (⌘K)"
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" />
|
||||
<span class="text-xs">Search</span>
|
||||
<span class="text-xs">{t("Search")}</span>
|
||||
<kbd class="hidden lg:inline-flex items-center gap-0.5 rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:border-white/20 dark:bg-white/10 dark:text-gray-400">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<.link
|
||||
:if={@current_organization}
|
||||
navigate={~p"/alerts"}
|
||||
class="relative p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
|
||||
title={t("Alerts")}
|
||||
>
|
||||
<.icon name="hero-bell" class="h-5 w-5" />
|
||||
</.link>
|
||||
|
||||
<!-- Help icon -->
|
||||
<.link
|
||||
navigate={~p"/help"}
|
||||
class="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
|
||||
title={t("Help")}
|
||||
>
|
||||
<.icon name="hero-question-mark-circle" class="h-5 w-5" />
|
||||
</.link>
|
||||
|
||||
<.theme_toggle />
|
||||
|
||||
<!-- User menu -->
|
||||
<button
|
||||
type="button"
|
||||
id="org-menu-button"
|
||||
|
|
@ -290,100 +427,64 @@ defmodule ToweropsWeb.Layouts do
|
|||
JS.toggle(to: "#org-menu")
|
||||
|> JS.toggle_attribute({"aria-expanded", "true", "false"}, to: "#org-menu-button")
|
||||
}
|
||||
class="inline-flex items-center justify-center gap-x-1 rounded-md bg-white px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
|
||||
class="inline-flex items-center justify-center rounded-full p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
|
||||
title={t("Menu")}
|
||||
>
|
||||
<span class="hidden sm:inline">
|
||||
{if @current_organization, do: @current_organization.name, else: t("Menu")}
|
||||
</span>
|
||||
<span class="sm:hidden">
|
||||
{if @current_organization,
|
||||
do: String.slice(@current_organization.name, 0..10),
|
||||
else: t("Menu")}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
data-slot="icon"
|
||||
aria-hidden="true"
|
||||
class="-mr-0.5 size-4 sm:size-5 text-gray-400"
|
||||
>
|
||||
<path
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<.icon name="hero-user-circle" class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="org-menu"
|
||||
role="menu"
|
||||
class="hidden absolute top-full right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg outline-1 outline-black/5 z-50 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
class="hidden absolute top-full right-0 mt-2 w-56 origin-top-right 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: "#org-menu")
|
||||
|> JS.set_attribute({"aria-expanded", "false"}, to: "#org-menu-button")
|
||||
}
|
||||
>
|
||||
<div class="py-1">
|
||||
<.link
|
||||
:if={@current_organization}
|
||||
role="menuitem"
|
||||
navigate={~p"/orgs"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
phx-click={JS.hide(to: "#org-menu")}
|
||||
>
|
||||
Switch Org
|
||||
</.link>
|
||||
<.link
|
||||
:if={@current_organization}
|
||||
role="menuitem"
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
phx-click={JS.hide(to: "#org-menu")}
|
||||
>
|
||||
Organization Settings
|
||||
</.link>
|
||||
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/admin"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
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: "#org-menu")}
|
||||
>
|
||||
Admin Panel
|
||||
{t("Admin Panel")}
|
||||
</.link>
|
||||
<% end %>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/users/settings"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
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: "#org-menu")}
|
||||
>
|
||||
User Settings
|
||||
{t("User Settings")}
|
||||
</.link>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/users/my-data"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
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: "#org-menu")}
|
||||
>
|
||||
My Data
|
||||
{t("My Data")}
|
||||
</.link>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/changelog"}
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
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: "#org-menu")}
|
||||
>
|
||||
🆕 What's New
|
||||
🆕 {t("What's New")}
|
||||
</.link>
|
||||
<div class="border-t border-gray-100 dark:border-white/10"></div>
|
||||
<.link
|
||||
role="menuitem"
|
||||
href={~p"/users/log-out"}
|
||||
method="delete"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
>
|
||||
Log out
|
||||
{t("Log out")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -391,56 +492,100 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<!-- Mobile slide-out panel -->
|
||||
<div
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
class="hidden md:hidden"
|
||||
id="mobile-menu"
|
||||
class="hidden fixed inset-0 z-50 md:hidden"
|
||||
>
|
||||
<div class="space-y-1 px-2 pb-3 pt-2 border-t border-gray-200 dark:border-white/10">
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/dashboard"}
|
||||
active={@active_page == "dashboard"}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 dark:bg-black/50"
|
||||
phx-click={
|
||||
JS.hide(to: "#mobile-menu")
|
||||
|> JS.toggle(to: "#mobile-menu-icon-open")
|
||||
|> JS.toggle(to: "#mobile-menu-icon-close")
|
||||
}
|
||||
>
|
||||
</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")
|
||||
|> JS.toggle(to: "#mobile-menu-icon-open")
|
||||
|> JS.toggle(to: "#mobile-menu-icon-close")
|
||||
}
|
||||
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-home" class="size-5" /> Dashboard
|
||||
</.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" /> Sites
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/devices"}
|
||||
active={@active_page == "devices"}
|
||||
>
|
||||
<.icon name="hero-server" class="size-5" /> Devices
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
>
|
||||
<.icon name="hero-bell" class="size-5" /> Alerts
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/insights"}
|
||||
active={@active_page == "insights"}
|
||||
>
|
||||
<.icon name="hero-light-bulb" class="size-5" /> Insights
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/trace"}
|
||||
active={@active_page == "trace"}
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="size-5" /> Trace
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/activity"}
|
||||
active={@active_page == "activity"}
|
||||
>
|
||||
<.icon name="hero-clock" class="size-5" /> Activity
|
||||
</.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>
|
||||
|
||||
<!-- Primary nav -->
|
||||
<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("Navigation")}
|
||||
</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"/alerts"} active={@active_page == "alerts"}>
|
||||
<.icon name="hero-bell" class="size-5" /> {t("Alerts")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- Secondary nav -->
|
||||
<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("Tools")}
|
||||
</p>
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<!-- Help & account -->
|
||||
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
|
||||
<.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>
|
||||
</nav>
|
||||
|
|
@ -541,11 +686,11 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.link
|
||||
navigate={@navigate}
|
||||
class={[
|
||||
"inline-flex items-center border-b-2 px-1 h-14 sm:h-16 text-sm font-medium",
|
||||
"inline-flex items-center border-b-2 px-2 h-14 sm:h-16 text-sm transition-colors",
|
||||
if(@active,
|
||||
do: "border-gray-900 text-gray-900 dark:border-white dark:text-white",
|
||||
do: "border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
|
||||
else:
|
||||
"border-transparent text-gray-700 hover:border-gray-300 hover:text-gray-900 dark:text-gray-300 dark:hover:border-gray-600 dark:hover:text-white"
|
||||
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 font-medium dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
|> assign(:filter, "unresolved")
|
||||
|> assign(:sort_by, "severity")
|
||||
|> assign(:expanded_sites, MapSet.new())
|
||||
|> assign(:expanded_alert, nil)
|
||||
|> assign(:selected_alerts, MapSet.new())
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
||||
|> load_alerts(organization.id)}
|
||||
end
|
||||
|
||||
|
|
@ -83,6 +86,80 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_alert", %{"id" => id}, socket) do
|
||||
expanded = if socket.assigns.expanded_alert == id, do: nil, else: id
|
||||
{:noreply, assign(socket, :expanded_alert, expanded)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_select", %{"id" => id}, socket) do
|
||||
selected = socket.assigns.selected_alerts
|
||||
|
||||
selected =
|
||||
if MapSet.member?(selected, id),
|
||||
do: MapSet.delete(selected, id),
|
||||
else: MapSet.put(selected, id)
|
||||
|
||||
{:noreply, assign(socket, :selected_alerts, selected)}
|
||||
end
|
||||
|
||||
def handle_event("select_all", _params, socket) do
|
||||
all_ids = Enum.map(socket.assigns.alerts, & &1.id)
|
||||
{:noreply, assign(socket, :selected_alerts, MapSet.new(all_ids))}
|
||||
end
|
||||
|
||||
def handle_event("select_none", _params, socket) do
|
||||
{:noreply, assign(socket, :selected_alerts, MapSet.new())}
|
||||
end
|
||||
|
||||
def handle_event("bulk_acknowledge", _params, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
user_id = socket.assigns.current_scope.user.id
|
||||
|
||||
count =
|
||||
Enum.reduce(socket.assigns.selected_alerts, 0, fn id, acc ->
|
||||
case AccessControl.verify_alert_access(id, organization.id) do
|
||||
{:ok, alert} ->
|
||||
case Alerts.acknowledge_alert(alert, user_id) do
|
||||
{:ok, _} -> acc + 1
|
||||
_ -> acc
|
||||
end
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_alerts, MapSet.new())
|
||||
|> put_flash(:info, "#{count} alert(s) acknowledged")
|
||||
|> load_alerts(organization.id)}
|
||||
end
|
||||
|
||||
def handle_event("bulk_resolve", _params, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
count =
|
||||
Enum.reduce(socket.assigns.selected_alerts, 0, fn id, acc ->
|
||||
case AccessControl.verify_alert_access(id, organization.id) do
|
||||
{:ok, alert} ->
|
||||
case Alerts.resolve_alert(alert) do
|
||||
{:ok, _} -> acc + 1
|
||||
_ -> acc
|
||||
end
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_alerts, MapSet.new())
|
||||
|> put_flash(:info, "#{count} alert(s) resolved")
|
||||
|> load_alerts(organization.id)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_site", %{"site-id" => site_id}, socket) do
|
||||
expanded = socket.assigns.expanded_sites
|
||||
|
||||
|
|
@ -271,9 +348,34 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
|
||||
defp format_number(number), do: to_string(number)
|
||||
|
||||
defp format_mrr(%Decimal{} = amount) do
|
||||
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
|
||||
@doc false
|
||||
def duration_text(alert) do
|
||||
end_time = alert.resolved_at || DateTime.utc_now()
|
||||
minutes = DateTime.diff(end_time, alert.triggered_at, :minute)
|
||||
|
||||
cond do
|
||||
minutes < 1 -> "<1m"
|
||||
minutes < 60 -> "#{minutes}m"
|
||||
minutes < 1440 -> "#{div(minutes, 60)}h #{rem(minutes, 60)}m"
|
||||
true -> "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_mrr(_), do: nil
|
||||
defp severity_badge_class(alert) do
|
||||
color = severity_color(alert)
|
||||
|
||||
case color do
|
||||
"red" -> "bg-red-500"
|
||||
"orange" -> "bg-orange-500"
|
||||
"yellow" -> "bg-yellow-500"
|
||||
"green" -> "bg-green-500"
|
||||
"gray" -> "bg-gray-300 dark:bg-gray-600"
|
||||
_ -> "bg-gray-300"
|
||||
end
|
||||
end
|
||||
|
||||
defp impact_text(alert, site_subs) do
|
||||
count = get_subscriber_count(alert, site_subs)
|
||||
if count > 0, do: "#{format_number(count)} subs"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,309 +3,497 @@
|
|||
current_scope={@current_scope}
|
||||
active_page="alerts"
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
Alerts <span class="badge badge-info badge-sm">Experimental</span>
|
||||
</span>
|
||||
<:subtitle>{t("Triage alerts by site impact — fix the biggest problems first")}</:subtitle>
|
||||
</.header>
|
||||
<%!-- Compact header --%>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Alerts")}</h1>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
|
||||
{t("Experimental")}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Triage alerts by site impact — fix the biggest problems first")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Filter Tabs --%>
|
||||
<div class="tabs tabs-boxed bg-base-200 mb-4 inline-flex">
|
||||
<%!-- Filter pills --%>
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<.link
|
||||
patch={~p"/alerts?filter=unresolved&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "unresolved" && "tab-active"]}
|
||||
class={[
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "unresolved" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "unresolved" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
Unresolved <span class="badge badge-sm ml-1">{@counts.unresolved}</span>
|
||||
{t("Unresolved")}
|
||||
<span class={[
|
||||
"rounded-full px-1.5 py-0.5 text-xs",
|
||||
@filter == "unresolved" && "bg-white/20 dark:bg-gray-900/30",
|
||||
@filter != "unresolved" && "bg-gray-200 dark:bg-gray-700"
|
||||
]}>
|
||||
{@counts.unresolved}
|
||||
</span>
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=critical&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "critical" && "tab-active"]}
|
||||
class={[
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "critical" && "bg-red-600 text-white",
|
||||
@filter != "critical" &&
|
||||
"bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
|
||||
]}
|
||||
>
|
||||
Critical <span class="badge badge-error badge-sm ml-1">{@counts.critical}</span>
|
||||
{t("Critical")}
|
||||
<span class={[
|
||||
"rounded-full px-1.5 py-0.5 text-xs",
|
||||
@filter == "critical" && "bg-white/20",
|
||||
@filter != "critical" && "bg-red-100 dark:bg-red-900/40"
|
||||
]}>
|
||||
{@counts.critical}
|
||||
</span>
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=all&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "all" && "tab-active"]}
|
||||
class={[
|
||||
"inline-flex items-center gap-1.5 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"
|
||||
]}
|
||||
>
|
||||
All <span class="badge badge-sm ml-1">{@counts.all}</span>
|
||||
{t("All")}
|
||||
<span class={[
|
||||
"rounded-full px-1.5 py-0.5 text-xs",
|
||||
@filter == "all" && "bg-white/20 dark:bg-gray-900/30",
|
||||
@filter != "all" && "bg-gray-200 dark:bg-gray-700"
|
||||
]}>
|
||||
{@counts.all}
|
||||
</span>
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=resolved&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "resolved" && "tab-active"]}
|
||||
class={[
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
@filter == "resolved" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
||||
@filter != "resolved" &&
|
||||
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
]}
|
||||
>
|
||||
Resolved <span class="badge badge-ghost badge-sm ml-1">{@counts.resolved}</span>
|
||||
{t("Resolved")}
|
||||
<span class={[
|
||||
"rounded-full px-1.5 py-0.5 text-xs",
|
||||
@filter == "resolved" && "bg-white/20 dark:bg-gray-900/30",
|
||||
@filter != "resolved" && "bg-gray-200 dark:bg-gray-700"
|
||||
]}>
|
||||
{@counts.resolved}
|
||||
</span>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- Sort Controls --%>
|
||||
<div class="flex items-center gap-2 mb-6 text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400 font-medium">Sort:</span>
|
||||
<span class="text-gray-300 dark:text-gray-600 mx-1">|</span>
|
||||
|
||||
<%!-- Sort controls --%>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{t("Sort")}:</span>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=#{@filter}&sort=severity"}
|
||||
class={["btn btn-xs", (@sort_by == "severity" && "btn-primary") || "btn-ghost"]}
|
||||
class={[
|
||||
"text-xs px-2 py-1 rounded transition-colors",
|
||||
@sort_by == "severity" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 font-medium",
|
||||
@sort_by != "severity" &&
|
||||
"text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
]}
|
||||
>
|
||||
{t("Severity")}
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=#{@filter}&sort=age"}
|
||||
class={["btn btn-xs", (@sort_by == "age" && "btn-primary") || "btn-ghost"]}
|
||||
class={[
|
||||
"text-xs px-2 py-1 rounded transition-colors",
|
||||
@sort_by == "age" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 font-medium",
|
||||
@sort_by != "age" &&
|
||||
"text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
]}
|
||||
>
|
||||
{t("Oldest First")}
|
||||
{t("Oldest")}
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=#{@filter}&sort=impact"}
|
||||
class={["btn btn-xs", (@sort_by == "impact" && "btn-primary") || "btn-ghost"]}
|
||||
class={[
|
||||
"text-xs px-2 py-1 rounded transition-colors",
|
||||
@sort_by == "impact" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 font-medium",
|
||||
@sort_by != "impact" &&
|
||||
"text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
]}
|
||||
>
|
||||
{t("Subscriber Impact")}
|
||||
{t("Impact")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- Bulk actions bar --%>
|
||||
<div
|
||||
:if={MapSet.size(@selected_alerts) > 0}
|
||||
class="flex items-center gap-3 mb-3 px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<span class="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||
{MapSet.size(@selected_alerts)} {t("selected")}
|
||||
</span>
|
||||
<.button type="button" phx-click="bulk_acknowledge" variant="primary">
|
||||
<.icon name="hero-check" class="h-3.5 w-3.5" /> {t("Ack Selected")}
|
||||
</.button>
|
||||
<.button type="button" phx-click="bulk_resolve" data-confirm={t("Resolve selected alerts?")}>
|
||||
<.icon name="hero-check-circle" class="h-3.5 w-3.5" /> {t("Resolve Selected")}
|
||||
</.button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="select_none"
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:underline ml-auto"
|
||||
>
|
||||
{t("Clear selection")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@alerts) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="card bg-base-100 shadow-md border border-green-200 dark:border-green-800 max-w-md w-full">
|
||||
<div class="card-body items-center text-center">
|
||||
<div class="rounded-full bg-green-100 dark:bg-green-900/40 p-4 mb-2">
|
||||
<.icon
|
||||
name="hero-check-circle-solid"
|
||||
class="h-12 w-12 text-green-500 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="card-title text-green-700 dark:text-green-400">{t("All Clear!")}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= case @filter do %>
|
||||
<% "unresolved" -> %>
|
||||
{t("No active alerts. Your network is looking good!")}
|
||||
<% "critical" -> %>
|
||||
{t("No critical alerts. Nice work keeping things healthy!")}
|
||||
<% "resolved" -> %>
|
||||
{t("No resolved alerts yet.")}
|
||||
<% _ -> %>
|
||||
{t("No active alerts. Your network is looking good!")}
|
||||
<% end %>
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900/40 p-4 mb-4">
|
||||
<.icon
|
||||
name="hero-check-circle-solid"
|
||||
class="h-12 w-12 text-green-500 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-green-700 dark:text-green-400">
|
||||
{t("All Clear!")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 max-w-sm">
|
||||
<%= case @filter do %>
|
||||
<% "unresolved" -> %>
|
||||
{t("No active alerts. Your network is looking good!")}
|
||||
<% "critical" -> %>
|
||||
{t("No critical alerts. Nice work keeping things healthy!")}
|
||||
<% "resolved" -> %>
|
||||
{t("No resolved alerts yet.")}
|
||||
<% _ -> %>
|
||||
{t("No active alerts. Your network is looking good!")}
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Grouped by Site --%>
|
||||
<div class="space-y-4">
|
||||
<%= for group <- @grouped_alerts do %>
|
||||
<div class={[
|
||||
"collapse collapse-arrow border rounded-xl shadow-sm",
|
||||
group.critical_count > 0 &&
|
||||
"border-red-300 dark:border-red-800 bg-red-50/50 dark:bg-red-950/30",
|
||||
group.critical_count == 0 && "border-base-300 bg-base-100"
|
||||
]}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
MapSet.member?(@expanded_sites, group.site_id || "none") || group.critical_count > 0
|
||||
}
|
||||
phx-click="toggle_site"
|
||||
phx-value-site-id={group.site_id || "none"}
|
||||
/>
|
||||
|
||||
<%!-- Site Header --%>
|
||||
<div class="collapse-title">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon
|
||||
name={
|
||||
if group.critical_count > 0,
|
||||
do: "hero-exclamation-triangle",
|
||||
else: "hero-signal"
|
||||
}
|
||||
<%!-- Alert table --%>
|
||||
<div class="flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th scope="col" class="py-2 pl-4 pr-2 w-8">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="select_all"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={t("Select all")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="h-4 w-4" />
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-1 px-0 py-2"></th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Device")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Site")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Message")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Duration")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Impact")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
<%= for alert <- @alerts do %>
|
||||
<tr
|
||||
class={[
|
||||
"h-5 w-5",
|
||||
group.critical_count > 0 && "text-red-500",
|
||||
group.critical_count == 0 && "text-gray-400"
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer",
|
||||
MapSet.member?(@selected_alerts, alert.id) &&
|
||||
"bg-blue-50/50 dark:bg-blue-900/10",
|
||||
rem(Enum.find_index(@alerts, &(&1.id == alert.id)) || 0, 2) == 1 &&
|
||||
"bg-gray-50/40 dark:bg-gray-800/20"
|
||||
]}
|
||||
/>
|
||||
<span class="font-semibold text-gray-900 dark:text-white text-lg">
|
||||
<%= if group.site_id do %>
|
||||
<.link
|
||||
navigate={~p"/sites/#{group.site_id}"}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{group.site_name}
|
||||
</.link>
|
||||
<% else %>
|
||||
{group.site_name}
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
<%!-- Alert count badge --%>
|
||||
<span class={[
|
||||
"badge",
|
||||
group.critical_count > 0 && "badge-error",
|
||||
group.critical_count == 0 && "badge-ghost"
|
||||
]}>
|
||||
{group.total_count} alert{if group.total_count != 1, do: "s"}
|
||||
</span>
|
||||
|
||||
<%= if group.critical_count > 0 do %>
|
||||
<span class="badge badge-error badge-outline badge-sm">
|
||||
{group.critical_count} down
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Subscriber impact for site --%>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<%= if group.subscriber_info && group.total_subscribers > 0 do %>
|
||||
<span class="badge badge-warning badge-outline gap-1">
|
||||
<.icon name="hero-users" class="h-3.5 w-3.5" />
|
||||
{format_number(group.total_subscribers)} subscribers
|
||||
</span>
|
||||
<%= if group.subscriber_info.total_mrr do %>
|
||||
<span class="badge badge-warning badge-outline gap-1">
|
||||
<.icon name="hero-currency-dollar" class="h-3.5 w-3.5" />
|
||||
{format_mrr(group.subscriber_info.total_mrr)}/mo
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Alerts within site --%>
|
||||
<div class="collapse-content">
|
||||
<div class="space-y-2 pt-2">
|
||||
<%= for alert <- group.alerts do %>
|
||||
<% color = severity_color(alert) %>
|
||||
<div class={[
|
||||
"rounded-lg border p-4 flex items-start justify-between gap-4",
|
||||
color == "red" &&
|
||||
"border-l-4 border-l-red-500 bg-red-50 dark:bg-red-950/50 border-red-200 dark:border-red-800",
|
||||
color == "orange" &&
|
||||
"border-l-4 border-l-orange-500 bg-orange-50 dark:bg-orange-950/50 border-orange-200 dark:border-orange-800",
|
||||
color == "yellow" &&
|
||||
"border-l-4 border-l-yellow-500 bg-yellow-50 dark:bg-yellow-950/50 border-yellow-200 dark:border-yellow-800",
|
||||
color == "green" &&
|
||||
"border-l-4 border-l-green-500 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800",
|
||||
color == "gray" &&
|
||||
"border-l-4 border-l-gray-300 bg-base-200/50 border-base-300 opacity-60"
|
||||
]}>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<%!-- Alert type badge --%>
|
||||
<span class={[
|
||||
"badge gap-1",
|
||||
alert.alert_type == :device_down && is_nil(alert.resolved_at) &&
|
||||
"badge-error",
|
||||
alert.alert_type == :device_down && alert.resolved_at && "badge-ghost",
|
||||
alert.alert_type == :device_up && "badge-success"
|
||||
]}>
|
||||
<%= case alert.alert_type do %>
|
||||
<% :device_down -> %>
|
||||
<.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Down
|
||||
<% :device_up -> %>
|
||||
<.icon name="hero-check-circle" class="h-3.5 w-3.5" /> Recovered
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
<%!-- Age badge --%>
|
||||
<span class={[
|
||||
"badge badge-sm",
|
||||
color == "red" && "badge-error badge-outline",
|
||||
color == "orange" && "badge-warning badge-outline",
|
||||
color not in ["red", "orange"] && "badge-ghost"
|
||||
]}>
|
||||
{age_text(alert)}
|
||||
</span>
|
||||
|
||||
<%!-- Status badges --%>
|
||||
<%= if alert.resolved_at do %>
|
||||
<span class="badge badge-ghost badge-sm">Resolved</span>
|
||||
<% end %>
|
||||
<%= if alert.acknowledged_at && is_nil(alert.resolved_at) do %>
|
||||
<span class="badge badge-info badge-sm badge-outline">Ack'd</span>
|
||||
<% end %>
|
||||
|
||||
<%!-- Per-alert subscriber impact --%>
|
||||
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
|
||||
<span class="badge badge-warning badge-sm gap-1">
|
||||
<.icon name="hero-users" class="h-3 w-3" />
|
||||
{alert.gaiia_impact["total_subscribers"]} affected
|
||||
</span>
|
||||
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
|
||||
<span class="badge badge-warning badge-sm gap-1">
|
||||
${alert.gaiia_impact["total_mrr"]}/mo
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
phx-click="toggle_alert"
|
||||
phx-value-id={alert.id}
|
||||
>
|
||||
<%!-- Checkbox --%>
|
||||
<td class="py-2 pl-4 pr-2" phx-click="toggle_select" phx-value-id={alert.id}>
|
||||
<div class={[
|
||||
"h-4 w-4 rounded border flex items-center justify-center transition-colors",
|
||||
MapSet.member?(@selected_alerts, alert.id) && "bg-blue-500 border-blue-500",
|
||||
!MapSet.member?(@selected_alerts, alert.id) &&
|
||||
"border-gray-300 dark:border-gray-600"
|
||||
]}>
|
||||
<.icon
|
||||
:if={MapSet.member?(@selected_alerts, alert.id)}
|
||||
name="hero-check"
|
||||
class="h-3 w-3 text-white"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<%!-- Device name + message --%>
|
||||
<div class="mt-2">
|
||||
<%!-- Severity bar --%>
|
||||
<td class="px-0 py-0 w-1">
|
||||
<div class={"h-full w-1 min-h-[2.5rem] #{severity_badge_class(alert)} rounded-sm"} />
|
||||
</td>
|
||||
|
||||
<%!-- Device --%>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded",
|
||||
alert.alert_type == :device_down && is_nil(alert.resolved_at) &&
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
alert.alert_type == :device_down && alert.resolved_at &&
|
||||
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
alert.alert_type == :device_up &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300"
|
||||
]}>
|
||||
<%= if alert.alert_type == :device_down do %>
|
||||
<.icon name="hero-arrow-down" class="h-3 w-3" /> DOWN
|
||||
<% else %>
|
||||
<.icon name="hero-arrow-up" class="h-3 w-3" /> UP
|
||||
<% end %>
|
||||
</span>
|
||||
<.link
|
||||
navigate={~p"/devices/#{alert.device.id}"}
|
||||
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{alert.device.name}
|
||||
</.link>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
||||
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">
|
||||
{alert.device.ip_address}
|
||||
</span>
|
||||
</div>
|
||||
<%= if alert.message do %>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{alert.message}</p>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<%!-- Timestamps --%>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500 mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>
|
||||
Triggered: {ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.triggered_at,
|
||||
@timezone
|
||||
)}
|
||||
<%!-- Site --%>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-600 dark:text-gray-400">
|
||||
<%= if alert.device.site do %>
|
||||
<.link
|
||||
navigate={~p"/sites/#{alert.device.site.id}"}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{alert.device.site.name}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<%!-- Message --%>
|
||||
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{alert.message || "—"}
|
||||
</td>
|
||||
|
||||
<%!-- Duration --%>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class={[
|
||||
"text-xs font-mono",
|
||||
severity_color(alert) == "red" &&
|
||||
"text-red-600 dark:text-red-400 font-medium",
|
||||
severity_color(alert) == "orange" && "text-orange-600 dark:text-orange-400",
|
||||
severity_color(alert) not in ["red", "orange"] &&
|
||||
"text-gray-600 dark:text-gray-400"
|
||||
]}>
|
||||
{duration_text(alert)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<%!-- Impact --%>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<%= if impact = impact_text(alert, @site_subscribers) do %>
|
||||
<span class="inline-flex items-center gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
<.icon name="hero-users" class="h-3 w-3" />
|
||||
{impact}
|
||||
</span>
|
||||
<%= if alert.acknowledged_at do %>
|
||||
<span>
|
||||
Ack'd: {ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.acknowledged_at,
|
||||
@timezone
|
||||
)}
|
||||
<%= if alert.acknowledged_by do %>
|
||||
by {alert.acknowledged_by.email}
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-xs text-gray-300 dark:text-gray-600">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<%!-- Actions --%>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<%= if alert.acknowledged_at && is_nil(alert.resolved_at) do %>
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20">
|
||||
Ack'd
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if alert.resolved_at do %>
|
||||
<span>
|
||||
Resolved: {ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.resolved_at,
|
||||
@timezone
|
||||
)}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
{t("Resolved")}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="acknowledge"
|
||||
phx-value-id={alert.id}
|
||||
class="text-xs px-2 py-1 rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
Ack
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if is_nil(alert.resolved_at) do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="resolve"
|
||||
phx-value-id={alert.id}
|
||||
data-confirm={t("Resolve this alert?")}
|
||||
class="text-xs px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<%!-- Action Buttons --%>
|
||||
<div class="flex flex-col gap-2 flex-shrink-0">
|
||||
<%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
|
||||
<.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary">
|
||||
<.icon name="hero-check" class="h-4 w-4" /> Ack
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if is_nil(alert.resolved_at) do %>
|
||||
<.button
|
||||
phx-click="resolve"
|
||||
phx-value-id={alert.id}
|
||||
data-confirm={t("Resolve this alert?")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="h-4 w-4" /> Resolve
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%!-- Expanded alert detail --%>
|
||||
<%= if @expanded_alert == alert.id do %>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800/40">
|
||||
<td colspan="8" class="px-4 py-3">
|
||||
<div class="flex gap-8 text-xs">
|
||||
<%!-- Timeline --%>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wider">
|
||||
{t("Timeline")}
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-red-500 flex-shrink-0">
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 w-20 flex-shrink-0">
|
||||
{t("Triggered")}
|
||||
</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">
|
||||
{ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.triggered_at,
|
||||
@timezone
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<%= if alert.acknowledged_at do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-blue-500 flex-shrink-0">
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 w-20 flex-shrink-0">
|
||||
{t("Ack'd")}
|
||||
</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">
|
||||
{ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.acknowledged_at,
|
||||
@timezone
|
||||
)}
|
||||
</span>
|
||||
<%= if alert.acknowledged_by do %>
|
||||
<span class="text-gray-500">
|
||||
by {alert.acknowledged_by.email}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if alert.resolved_at do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-green-500 flex-shrink-0">
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 w-20 flex-shrink-0">
|
||||
{t("Resolved")}
|
||||
</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">
|
||||
{ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.resolved_at,
|
||||
@timezone
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Impact details --%>
|
||||
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wider">
|
||||
{t("Impact")}
|
||||
</h4>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon name="hero-users" class="h-3.5 w-3.5 text-amber-500" />
|
||||
<span class="text-gray-900 dark:text-white font-medium">
|
||||
{format_number(alert.gaiia_impact["total_subscribers"])} subscribers affected
|
||||
</span>
|
||||
</div>
|
||||
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon
|
||||
name="hero-currency-dollar"
|
||||
class="h-3.5 w-3.5 text-amber-500"
|
||||
/>
|
||||
<span class="text-gray-900 dark:text-white font-medium">
|
||||
${alert.gaiia_impact["total_mrr"]}/mo at risk
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Message --%>
|
||||
<%= if alert.message do %>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wider">
|
||||
{t("Details")}
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400">{alert.message}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -189,19 +189,6 @@ defmodule ToweropsWeb.DashboardLive do
|
|||
defp health_score_color(score) when score > 50, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp health_score_color(_score), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp health_score_bg(score) when score > 80,
|
||||
do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||
|
||||
defp health_score_bg(score) when score > 50,
|
||||
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800"
|
||||
|
||||
defp health_score_bg(_score), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||
|
||||
defp urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
defp urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
defp urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
defp urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
|
||||
defp source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
defp source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
|
||||
defp source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
|
|
@ -277,12 +264,6 @@ defmodule ToweropsWeb.DashboardLive do
|
|||
defp uptime_color(pct) when pct >= 95.0, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp uptime_color(_pct), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp uptime_bg(pct) when pct >= 99.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||
|
||||
defp uptime_bg(pct) when pct >= 95.0, do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800"
|
||||
|
||||
defp uptime_bg(_pct), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||
|
||||
defp format_qoe(nil), do: "—"
|
||||
defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|
||||
|> assign(:device_quota, %{current: current, limit: limit})
|
||||
|> assign(:site_subscribers, site_subscribers)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:status_filter, "all")
|
||||
|> assign(:pending_reload_ref, nil)}
|
||||
end
|
||||
|
||||
|
|
@ -97,6 +99,24 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"search_query" => query}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:search_query, query)
|
||||
|> apply_filters()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter_status", %{"status" => status}, socket) do
|
||||
new_status = if socket.assigns.status_filter == status, do: "all", else: status
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:status_filter, new_status)
|
||||
|> apply_filters()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("force_rediscover_all", _params, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
|
@ -324,6 +344,40 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|
||||
end
|
||||
|
||||
defp apply_filters(socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
devices = Devices.list_organization_devices(organization.id)
|
||||
filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter)
|
||||
|
||||
socket
|
||||
|> stream(:device_rows, build_device_rows(filtered), reset: true)
|
||||
|> assign(:has_devices, devices != [])
|
||||
end
|
||||
|
||||
defp filter_devices(devices, query, status_filter) do
|
||||
devices
|
||||
|> filter_by_search(query)
|
||||
|> filter_by_status(status_filter)
|
||||
end
|
||||
|
||||
defp filter_by_search(devices, ""), do: devices
|
||||
defp filter_by_search(devices, nil), do: devices
|
||||
|
||||
defp filter_by_search(devices, query) do
|
||||
q = String.downcase(query)
|
||||
|
||||
Enum.filter(devices, fn device ->
|
||||
String.contains?(String.downcase(device.name || ""), q) ||
|
||||
String.contains?(String.downcase(to_string(device.ip_address)), q)
|
||||
end)
|
||||
end
|
||||
|
||||
defp filter_by_status(devices, "all"), do: devices
|
||||
defp filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up))
|
||||
defp filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down))
|
||||
defp filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown))
|
||||
defp filter_by_status(devices, _), do: devices
|
||||
|
||||
defp enqueue_discovery(device_id) do
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
|
||||
|
|
@ -477,6 +531,25 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
{formatted, color}
|
||||
end
|
||||
|
||||
defp response_time_bar_width(nil), do: 0
|
||||
defp response_time_bar_width(%{response_time_ms: nil}), do: 0
|
||||
|
||||
defp response_time_bar_width(%{response_time_ms: ms}) do
|
||||
# Scale: 0-200ms maps to 0-100% width, capped at 100
|
||||
min(100, round(ms / 2))
|
||||
end
|
||||
|
||||
defp response_time_bar_color(nil), do: "bg-gray-300"
|
||||
defp response_time_bar_color(%{response_time_ms: nil}), do: "bg-gray-300"
|
||||
|
||||
defp response_time_bar_color(%{response_time_ms: ms}) do
|
||||
cond do
|
||||
ms < 20 -> "bg-green-500"
|
||||
ms <= 100 -> "bg-yellow-500"
|
||||
true -> "bg-red-500"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_subscriber_count(nil), do: nil
|
||||
|
||||
defp format_subscriber_count(%{subscriber_count: count}) when count > 0 do
|
||||
|
|
|
|||
|
|
@ -3,140 +3,118 @@
|
|||
current_scope={@current_scope}
|
||||
active_page="devices"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{t("Monitor and manage all your network devices")}</:subtitle>
|
||||
</.header>
|
||||
<%!-- Compact header --%>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{@page_title}</h1>
|
||||
<% percent =
|
||||
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
|
||||
(@device_quota.current / @device_quota.limit * 100) |> trunc()
|
||||
else
|
||||
0
|
||||
end %>
|
||||
<% badge_class =
|
||||
cond do
|
||||
@current_scope.user.is_superuser ->
|
||||
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
@device_quota.limit != :unlimited and @device_quota.current >= @device_quota.limit ->
|
||||
"bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"
|
||||
|
||||
percent >= 90 ->
|
||||
"bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400"
|
||||
|
||||
@device_quota.limit == :unlimited ->
|
||||
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
true ->
|
||||
"bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
|
||||
end %>
|
||||
<span class={"rounded px-2 py-0.5 text-xs font-medium #{badge_class}"}>
|
||||
<%= cond do %>
|
||||
<% @current_scope.user.is_superuser -> %>
|
||||
{@device_quota.current} devices
|
||||
<% @device_quota.limit == :unlimited -> %>
|
||||
{@device_quota.current} devices
|
||||
<% true -> %>
|
||||
{@device_quota.current}/{@device_quota.limit}
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% percent =
|
||||
if @device_quota.limit != :unlimited and @device_quota.limit > 0 do
|
||||
(@device_quota.current / @device_quota.limit * 100) |> trunc()
|
||||
else
|
||||
0
|
||||
end %>
|
||||
|
||||
<% badge_class =
|
||||
cond do
|
||||
@current_scope.user.is_superuser ->
|
||||
"bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
@device_quota.limit != :unlimited and @device_quota.current >= @device_quota.limit ->
|
||||
"bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800"
|
||||
|
||||
percent >= 90 ->
|
||||
"bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800"
|
||||
|
||||
percent >= 75 ->
|
||||
"bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800"
|
||||
|
||||
@device_quota.limit == :unlimited ->
|
||||
"bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
|
||||
true ->
|
||||
"bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800"
|
||||
end %>
|
||||
|
||||
<div class={"rounded-lg px-3 py-2 text-sm font-medium border #{badge_class}"}>
|
||||
<%= cond do %>
|
||||
<% @current_scope.user.is_superuser -> %>
|
||||
{@device_quota.current} devices
|
||||
<% @device_quota.limit == :unlimited -> %>
|
||||
{@device_quota.current} devices
|
||||
<% true -> %>
|
||||
{@device_quota.current}/{@device_quota.limit} devices
|
||||
<div class="flex items-center gap-2">
|
||||
<%= if @has_devices do %>
|
||||
<%= if @reorder_mode do %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="reset_order"
|
||||
data-confirm={t("Reset all sites and devices to alphabetical order?")}
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-4 w-4" /> Reset
|
||||
</.button>
|
||||
<.button type="button" phx-click="toggle_reorder_mode" variant="primary">
|
||||
<.icon name="hero-check" class="h-4 w-4" /> Done
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button type="button" phx-click="toggle_reorder_mode">
|
||||
<.icon name="hero-bars-3" class="h-4 w-4" />
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_compact_mode"
|
||||
variant={if @compact_mode, do: "primary", else: nil}
|
||||
>
|
||||
<.icon name="hero-bars-2" class="h-4 w-4" />
|
||||
</.button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mb-6">
|
||||
<%= if @has_devices do %>
|
||||
<%= if @reorder_mode do %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="reset_order"
|
||||
data-confirm={t("Reset all sites and devices to alphabetical order?")}
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_reorder_mode"
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-check" class="h-4 w-4" /> Done
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_reorder_mode"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="toggle_compact_mode"
|
||||
variant={if @compact_mode, do: "primary", else: nil}
|
||||
>
|
||||
<.icon name="hero-bars-2" class="h-4 w-4" /> Compact
|
||||
</.button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.button
|
||||
:if={!@sites_enabled || @has_sites}
|
||||
navigate={~p"/devices/new"}
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> New Device
|
||||
</.button>
|
||||
|
||||
<%= if @has_devices and not @reorder_mode do %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="force_rediscover_all"
|
||||
data-confirm={
|
||||
t("This will trigger SNMP discovery for all SNMP-enabled devices. Continue?")
|
||||
}
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
|
||||
<.button :if={!@sites_enabled || @has_sites} navigate={~p"/devices/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Device")}
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if @has_devices and not @reorder_mode do %>
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="force_rediscover_all"
|
||||
data-confirm={
|
||||
t("This will trigger SNMP discovery for all SNMP-enabled devices. Continue?")
|
||||
}
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" />
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-white/10 mb-6">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div class="border-b border-gray-200 dark:border-white/10 mb-4">
|
||||
<nav class="-mb-px flex space-x-6">
|
||||
<.link
|
||||
patch={~p"/devices?tab=existing"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
"whitespace-nowrap py-2 px-1 border-b-2 text-sm font-medium",
|
||||
if @active_tab == "existing" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{t("Existing Devices")}
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
patch={~p"/devices?tab=discovered"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
"whitespace-nowrap py-2 px-1 border-b-2 text-sm font-medium",
|
||||
if @active_tab == "discovered" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{t("Discovered Devices")}
|
||||
{t("Discovered")}
|
||||
<%= if @discovered_devices != [] do %>
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{length(@discovered_devices)}
|
||||
</span>
|
||||
<% end %>
|
||||
|
|
@ -147,29 +125,14 @@
|
|||
<%= case @active_tab do %>
|
||||
<% "existing" -> %>
|
||||
<%= if @sites_enabled && !@has_sites && @has_devices do %>
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
{t("Organize your devices with sites")}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<p>
|
||||
{t(
|
||||
"Sites help you organize devices by physical location. Create a site to assign your devices and keep things organized."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<.button navigate={~p"/sites/new"}>
|
||||
<.icon name="hero-plus" class="h-4 w-4" /> Create Your First Site
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 mb-4 flex items-center gap-3">
|
||||
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400 flex-shrink-0" />
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200 flex-1">
|
||||
{t("Organize your devices with sites")} —
|
||||
<.link navigate={~p"/sites/new"} class="font-medium underline">
|
||||
{t("Create a site")}
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
|
@ -184,171 +147,230 @@
|
|||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/devices/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> New Device
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Device")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Status Summary Bar -->
|
||||
<div class="flex items-center gap-4 px-4 py-2.5 mb-4 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{@total_devices} devices
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span class="inline-flex items-center gap-1.5 text-green-700 dark:text-green-400">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
{@total_up} up
|
||||
</span>
|
||||
<span
|
||||
:if={@total_down > 0}
|
||||
class="inline-flex items-center gap-1.5 text-red-700 dark:text-red-400"
|
||||
>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{@total_down} down
|
||||
</span>
|
||||
<span
|
||||
:if={@total_unknown > 0}
|
||||
class="inline-flex items-center gap-1.5 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
{@total_unknown} unknown
|
||||
</span>
|
||||
<%!-- Sticky Status Bar + Search --%>
|
||||
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900 pb-3 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-100 dark:border-white/5">
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<%!-- Search --%>
|
||||
<form phx-change="search" class="flex-1 max-w-sm">
|
||||
<div class="relative">
|
||||
<.icon
|
||||
name="hero-magnifying-glass"
|
||||
class="absolute left-2.5 top-2 h-4 w-4 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="search_query"
|
||||
value={@search_query}
|
||||
placeholder={t("Filter by name or IP...")}
|
||||
phx-debounce="150"
|
||||
class="block w-full rounded-md border-0 py-1.5 pl-9 pr-3 text-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-white/10 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<%!-- Status summary + filter buttons --%>
|
||||
<div class="flex items-center gap-2 text-xs font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400 font-mono">{@total_devices}</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="filter_status"
|
||||
phx-value-status="up"
|
||||
class={[
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md transition-colors",
|
||||
@status_filter == "up" &&
|
||||
"bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 ring-1 ring-green-300 dark:ring-green-700",
|
||||
@status_filter != "up" &&
|
||||
"text-green-700 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20"
|
||||
]}
|
||||
>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
{@total_up}
|
||||
</button>
|
||||
<button
|
||||
:if={@total_down > 0}
|
||||
type="button"
|
||||
phx-click="filter_status"
|
||||
phx-value-status="down"
|
||||
class={[
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md transition-colors",
|
||||
@status_filter == "down" &&
|
||||
"bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700",
|
||||
@status_filter != "down" &&
|
||||
"text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
]}
|
||||
>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{@total_down}
|
||||
</button>
|
||||
<button
|
||||
:if={@total_unknown > 0}
|
||||
type="button"
|
||||
phx-click="filter_status"
|
||||
phx-value-status="unknown"
|
||||
class={[
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md transition-colors",
|
||||
@status_filter == "unknown" &&
|
||||
"bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 ring-1 ring-gray-400 dark:ring-gray-600",
|
||||
@status_filter != "unknown" &&
|
||||
"text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
]}
|
||||
>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
{@total_unknown}
|
||||
</button>
|
||||
<button
|
||||
:if={@status_filter != "all"}
|
||||
type="button"
|
||||
phx-click="filter_status"
|
||||
phx-value-status="all"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 ml-1"
|
||||
title={t("Clear filter")}
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<%!-- Device Table --%>
|
||||
<div class="mt-2 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="relative min-w-full" phx-hook="DeviceListReorder" id="device-list">
|
||||
<thead class="bg-white dark:bg-gray-900">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th
|
||||
:if={@reorder_mode}
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-2 w-8 sm:pl-3"
|
||||
>
|
||||
<span class="sr-only">Drag handle</span>
|
||||
<th :if={@reorder_mode} scope="col" class="py-2 pl-4 pr-2 w-8 sm:pl-3">
|
||||
<span class="sr-only">Drag</span>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class={[
|
||||
"py-3.5 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:text-white",
|
||||
"py-2 pr-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
|
||||
@reorder_mode && "pl-2",
|
||||
!@reorder_mode && "pl-4"
|
||||
!@reorder_mode && "pl-4 sm:pl-3"
|
||||
]}
|
||||
>
|
||||
{t("Name")}
|
||||
{t("Device")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("IP Address")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Status")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Type")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Last Seen")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Response")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
||||
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Subscribers")}
|
||||
{t("Subs")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="device-rows" class="bg-white dark:bg-gray-900" phx-update="stream">
|
||||
<tbody
|
||||
id="device-rows"
|
||||
class="divide-y divide-gray-100 dark:divide-white/5"
|
||||
phx-update="stream"
|
||||
>
|
||||
<%= for {dom_id, row} <- @streams.device_rows do %>
|
||||
<%= if row.type == :site_header do %>
|
||||
<tr
|
||||
id={dom_id}
|
||||
class={[
|
||||
"border-t site-header border-gray-200",
|
||||
"dark:border-white/10"
|
||||
]}
|
||||
class="bg-gray-50/80 dark:bg-gray-800/30"
|
||||
data-site-id={if row.site, do: row.site.id, else: "no-site"}
|
||||
draggable={if @reorder_mode && row.site, do: "true", else: "false"}
|
||||
>
|
||||
<td
|
||||
:if={@reorder_mode && row.site}
|
||||
class="py-2 pl-4 pr-2 sm:pl-3 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<td :if={@reorder_mode && row.site} class="py-1.5 pl-4 pr-2 sm:pl-3">
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Drag to reorder site"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-5 w-5" />
|
||||
<.icon name="hero-bars-3" class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
<th
|
||||
scope="colgroup"
|
||||
colspan="6"
|
||||
class={[
|
||||
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
|
||||
!@reorder_mode && "pl-4 sm:pl-3"
|
||||
]}
|
||||
colspan={if @reorder_mode, do: "7", else: "7"}
|
||||
class={["py-1.5 pr-3", !@reorder_mode && "pl-4 sm:pl-3"]}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<%= if row.site do %>
|
||||
<.link
|
||||
navigate={~p"/sites/#{row.site.id}"}
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
|
||||
class="font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{row.site.name}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{if @sites_enabled,
|
||||
do: "Unassigned Devices",
|
||||
else: "All Devices"}
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300">
|
||||
{if @sites_enabled, do: "Unassigned", else: "All Devices"}
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{row.stats.total} {if row.stats.total == 1,
|
||||
do: "device",
|
||||
else: "devices"}
|
||||
<span class="text-gray-500 dark:text-gray-400 font-mono">
|
||||
{row.stats.total}
|
||||
</span>
|
||||
<span
|
||||
:if={row.stats.up > 0}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
class="text-green-600 dark:text-green-400"
|
||||
>
|
||||
{row.stats.up} up
|
||||
<span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500">
|
||||
</span>
|
||||
{row.stats.up}
|
||||
</span>
|
||||
<span
|
||||
:if={row.stats.down > 0}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
class="text-red-600 dark:text-red-400 font-medium"
|
||||
>
|
||||
{row.stats.down} down
|
||||
<span class="inline-block h-1.5 w-1.5 rounded-full bg-red-500">
|
||||
</span>
|
||||
{row.stats.down}
|
||||
</span>
|
||||
<%= if row.site && @site_subscribers[row.site.id] do %>
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-800 dark:bg-amber-400/10 dark:text-amber-400">
|
||||
<.icon name="hero-users" class="h-3 w-3" />
|
||||
{@site_subscribers[row.site.id].account_count} subscribers
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
<.icon name="hero-users" class="inline h-3 w-3" />
|
||||
{@site_subscribers[row.site.id].account_count}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p
|
||||
<span
|
||||
:if={row.site && row.site.location}
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<.icon name="hero-map-pin" class="inline h-3 w-3" /> {row.site.location}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -356,11 +378,8 @@
|
|||
<tr
|
||||
id={dom_id}
|
||||
class={[
|
||||
"border-t device-row hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors",
|
||||
if(row.device_index == 0,
|
||||
do: "border-gray-300 dark:border-white/15",
|
||||
else: "border-gray-200 dark:border-white/10"
|
||||
)
|
||||
"device-row hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors cursor-pointer",
|
||||
rem(row.device_index, 2) == 1 && "bg-gray-50/40 dark:bg-gray-800/20"
|
||||
]}
|
||||
title={device_row_title(row.device)}
|
||||
data-device-id={row.device.id}
|
||||
|
|
@ -372,7 +391,7 @@
|
|||
:if={@reorder_mode}
|
||||
class={[
|
||||
"pl-4 pr-2 sm:pl-3",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<button
|
||||
|
|
@ -380,7 +399,7 @@
|
|||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Drag to reorder device"
|
||||
>
|
||||
<.icon name="hero-bars-3" class="h-5 w-5" />
|
||||
<.icon name="hero-bars-3" class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
<td class="p-0 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">
|
||||
|
|
@ -390,40 +409,36 @@
|
|||
"flex items-center gap-2 pr-3 text-inherit no-underline",
|
||||
@reorder_mode && "pl-2",
|
||||
!@reorder_mode && "pl-4 sm:pl-3",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<span
|
||||
class={"inline-block h-2.5 w-2.5 rounded-full flex-shrink-0 #{health_dot_color(row.health)}"}
|
||||
class={"inline-block h-2 w-2 rounded-full flex-shrink-0 #{health_dot_color(row.health)}"}
|
||||
title={health_dot_title(row.health)}
|
||||
/>
|
||||
<.icon
|
||||
name={device_type_icon(row.device.device_role)}
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
/>
|
||||
{row.device.name}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-0 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono">
|
||||
<td class="p-0 whitespace-nowrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
"block px-3 text-inherit no-underline font-mono text-xs text-gray-600 dark:text-gray-300",
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
{row.device.ip_address}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-0 text-sm whitespace-nowrap">
|
||||
<td class="p-0 whitespace-nowrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class={[
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
row.device.status == :up && "bg-green-500",
|
||||
|
|
@ -431,7 +446,7 @@
|
|||
row.device.status == :unknown && "bg-gray-400"
|
||||
]} />
|
||||
<span class={[
|
||||
"text-xs font-medium",
|
||||
"text-xs font-mono font-medium",
|
||||
row.device.status == :up && "text-green-700 dark:text-green-400",
|
||||
row.device.status == :down && "text-red-700 dark:text-red-400",
|
||||
row.device.status == :unknown &&
|
||||
|
|
@ -442,45 +457,76 @@
|
|||
</span>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-0 text-sm whitespace-nowrap">
|
||||
<td class="p-0 whitespace-nowrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class={[
|
||||
"flex items-center gap-1.5 px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<.icon
|
||||
name={device_type_icon(row.device.device_role)}
|
||||
class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{(row.device.device_role || :unknown)
|
||||
|> to_string()
|
||||
|> String.capitalize()}
|
||||
</span>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-0 whitespace-nowrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<% {text, color} = last_seen_text(row.device.last_checked_at) %>
|
||||
<span class={"text-xs font-medium #{color}"}>{text}</span>
|
||||
<span class={"text-xs font-mono #{color}"}>{text}</span>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-0 text-sm whitespace-nowrap">
|
||||
<td class="p-0 whitespace-nowrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<% {rt_text, rt_color} = response_time_badge(row.response) %>
|
||||
<span class={"text-xs font-mono #{rt_color}"}>{rt_text}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<% {rt_text, rt_color} = response_time_badge(row.response) %>
|
||||
<span class={"text-xs font-mono #{rt_color}"}>{rt_text}</span>
|
||||
<% bar_w = response_time_bar_width(row.response) %>
|
||||
<div
|
||||
:if={bar_w > 0}
|
||||
class="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class={"h-full rounded-full #{response_time_bar_color(row.response)}"}
|
||||
style={"width: #{bar_w}%"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-0 text-sm whitespace-nowrap">
|
||||
<td class="p-0 whitespace-nowrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
if(@compact_mode, do: "py-1", else: "py-2")
|
||||
]}
|
||||
>
|
||||
<%= if sub_text = format_subscriber_count(row.subscribers) do %>
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-blue-700 dark:text-blue-300">
|
||||
<.icon name="hero-users" class="h-3 w-3" />
|
||||
{sub_text}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
<span class="text-xs text-gray-300 dark:text-gray-600">—</span>
|
||||
<% end %>
|
||||
</.link>
|
||||
</td>
|
||||
|
|
@ -515,16 +561,13 @@
|
|||
)} of {@pagination.total_count} discovered devices
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="discovered-devices"
|
||||
rows={@discovered_devices}
|
||||
>
|
||||
<.table id="discovered-devices" rows={@discovered_devices}>
|
||||
<:col :let={discovered} label={t("Identifier")}>
|
||||
<%= case discovered.identifier.type do %>
|
||||
<% :mac -> %>
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
{t("MAC")}
|
||||
MAC
|
||||
</span>
|
||||
<span class="ml-2 font-mono text-sm text-gray-900 dark:text-white">
|
||||
{discovered.identifier.value}
|
||||
|
|
@ -533,7 +576,7 @@
|
|||
<% :ip -> %>
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{t("IP")}
|
||||
IP
|
||||
</span>
|
||||
<span class="ml-2 font-mono text-sm text-gray-900 dark:text-white">
|
||||
{discovered.identifier.value}
|
||||
|
|
@ -542,7 +585,7 @@
|
|||
<% :hostname -> %>
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
{t("HOST")}
|
||||
HOST
|
||||
</span>
|
||||
<span class="ml-2 text-sm text-gray-900 dark:text-white">
|
||||
{discovered.identifier.value}
|
||||
|
|
@ -552,7 +595,6 @@
|
|||
<span class="text-sm text-gray-500">Unknown</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label={t("Hostname")}>
|
||||
<%= if discovered.hostname do %>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
|
|
@ -562,7 +604,6 @@
|
|||
<span class="text-sm text-gray-400">-</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label={t("Type")}>
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon
|
||||
|
|
@ -574,17 +615,13 @@
|
|||
</span>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label={t("Manufacturer")}>
|
||||
<%= if discovered.manufacturer do %>
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
{discovered.manufacturer}
|
||||
</span>
|
||||
<span class="text-sm text-gray-900 dark:text-white">{discovered.manufacturer}</span>
|
||||
<% else %>
|
||||
<span class="text-sm text-gray-400">-</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label={t("Discovered By")}>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= if length(discovered.discovered_by) == 1 do %>
|
||||
|
|
@ -601,21 +638,19 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label={t("Last Seen")}>
|
||||
<.timestamp
|
||||
datetime={discovered.last_seen}
|
||||
class="text-sm text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
</:col>
|
||||
|
||||
<:col :let={discovered} label={t("Actions")}>
|
||||
<.button
|
||||
phx-click="add_discovered_device"
|
||||
phx-value-identifier={Jason.encode!(discovered.identifier)}
|
||||
variant="primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3" /> Add Device
|
||||
<.icon name="hero-plus" class="h-3 w-3" /> Add
|
||||
</.button>
|
||||
</:col>
|
||||
</.table>
|
||||
|
|
@ -639,66 +674,50 @@
|
|||
</.link>
|
||||
</div>
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-400">
|
||||
{t("Showing")}
|
||||
<span class="font-medium">
|
||||
{(@pagination.page - 1) * @pagination.per_page + 1}
|
||||
</span>
|
||||
to
|
||||
<span class="font-medium">
|
||||
{min(@pagination.page * @pagination.per_page, @pagination.total_count)}
|
||||
</span>
|
||||
of <span class="font-medium">{@pagination.total_count}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
|
||||
aria-label={t("Pagination")}
|
||||
<p class="text-sm text-gray-700 dark:text-gray-400">
|
||||
<span class="font-medium">{(@pagination.page - 1) * @pagination.per_page + 1}</span>–<span class="font-medium">{min(@pagination.page * @pagination.per_page, @pagination.total_count)}</span> of
|
||||
<span class="font-medium">{@pagination.total_count}</span>
|
||||
</p>
|
||||
<nav
|
||||
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
|
||||
aria-label={t("Pagination")}
|
||||
>
|
||||
<.link
|
||||
:if={@pagination.page > 1}
|
||||
patch={~p"/devices?tab=discovered&page=#{@pagination.page - 1}"}
|
||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
|
||||
>
|
||||
<.link
|
||||
:if={@pagination.page > 1}
|
||||
patch={~p"/devices?tab=discovered&page=#{@pagination.page - 1}"}
|
||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<.icon name="hero-chevron-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
|
||||
<%= for page_num <- pagination_range(@pagination.page, @pagination.total_pages) do %>
|
||||
<%= if page_num == :ellipsis do %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 dark:text-gray-400 dark:ring-white/10">
|
||||
...
|
||||
</span>
|
||||
<% else %>
|
||||
<.link
|
||||
patch={~p"/devices?tab=discovered&page=#{page_num}"}
|
||||
class={[
|
||||
"relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 dark:ring-white/10",
|
||||
if @pagination.page == page_num do
|
||||
"z-10 bg-blue-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
else
|
||||
"text-gray-900 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{page_num}
|
||||
</.link>
|
||||
<% end %>
|
||||
<.icon name="hero-chevron-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<%= for page_num <- pagination_range(@pagination.page, @pagination.total_pages) do %>
|
||||
<%= if page_num == :ellipsis do %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 dark:text-gray-400 dark:ring-white/10">
|
||||
...
|
||||
</span>
|
||||
<% else %>
|
||||
<.link
|
||||
patch={~p"/devices?tab=discovered&page=#{page_num}"}
|
||||
class={[
|
||||
"relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 dark:ring-white/10",
|
||||
if @pagination.page == page_num do
|
||||
"z-10 bg-blue-600 text-white"
|
||||
else
|
||||
"text-gray-900 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{page_num}
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<.link
|
||||
:if={@pagination.page < @pagination.total_pages}
|
||||
patch={~p"/devices?tab=discovered&page=#{@pagination.page + 1}"}
|
||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<.icon name="hero-chevron-right" class="h-5 w-5" />
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
<% end %>
|
||||
<.link
|
||||
:if={@pagination.page < @pagination.total_pages}
|
||||
patch={~p"/devices?tab=discovered&page=#{@pagination.page + 1}"}
|
||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
|
||||
>
|
||||
<.icon name="hero-chevron-right" class="h-5 w-5" />
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
[%{label: @device.name}]
|
||||
} />
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="sticky top-0 z-30 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-3 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-white/5 mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1>
|
||||
|
|
@ -52,8 +52,16 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
<span
|
||||
class="text-sm text-gray-600 dark:text-gray-400 font-mono group/ip cursor-pointer"
|
||||
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
|
||||
title={t("Click to copy")}
|
||||
>
|
||||
{@device.ip_address}
|
||||
<.icon
|
||||
name="hero-clipboard-document"
|
||||
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">•</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -343,9 +351,17 @@
|
|||
</div>
|
||||
|
||||
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
|
||||
<dt class="text-sm text-gray-600 dark:text-gray-400">Resolved IP</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
|
||||
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Resolved IP")}</dt>
|
||||
<dd
|
||||
class="text-sm font-medium text-gray-900 dark:text-white font-mono group/rip cursor-pointer"
|
||||
phx-click={JS.dispatch("phx:copy", detail: %{text: @device.ip_address})}
|
||||
title={t("Click to copy")}
|
||||
>
|
||||
{@device.ip_address}
|
||||
<.icon
|
||||
name="hero-clipboard-document"
|
||||
class="h-3.5 w-3.5 inline ml-1 opacity-0 group-hover/rip:opacity-100 transition-opacity text-gray-400"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Preseem.Client, as: PreseemClient
|
||||
alias Towerops.Sonar.Client, as: SonarClient
|
||||
alias Towerops.Splynx.Client, as: SplynxClient
|
||||
alias Towerops.Visp.Client, as: VispClient
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@
|
|||
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t("Location")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t("Coordinates")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t("Last Check")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white dark:divide-white/5 dark:bg-gray-800/50">
|
||||
|
|
@ -69,7 +77,7 @@
|
|||
class="flex items-center gap-2 text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
||||
>
|
||||
<span class={[
|
||||
"h-2 w-2 rounded-full flex-shrink-0",
|
||||
"h-3 w-3 rounded-full flex-shrink-0",
|
||||
health_dot(@summary_map[site.id] && @summary_map[site.id].site_health)
|
||||
]}>
|
||||
</span>
|
||||
|
|
@ -110,6 +118,26 @@
|
|||
<td class="px-4 py-2.5 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{site.location || "—"}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<%= if site.latitude && site.longitude do %>
|
||||
<span class="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
{Float.round(site.latitude, 4)}, {Float.round(site.longitude, 4)}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-sm">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<.link
|
||||
navigate={~p"/devices/new?site_id=#{site.id}"}
|
||||
class="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3.5 w-3.5" /> {t("Add Device")}
|
||||
</.link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,33 @@
|
|||
%{label: "Sites", navigate: ~p"/sites"},
|
||||
%{label: @site.name}
|
||||
]} />
|
||||
<!-- Prominent site header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@site.name}</h1>
|
||||
<p :if={@site.location} class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<.icon name="hero-map-pin" class="h-4 w-4 inline" /> {@site.location}
|
||||
<%= if @site.latitude && @site.longitude do %>
|
||||
<span class="ml-2 font-mono text-xs text-gray-400">
|
||||
({Float.round(@site.latitude, 4)}, {Float.round(@site.longitude, 4)})
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet map if coordinates exist -->
|
||||
<%= if @site.latitude && @site.longitude do %>
|
||||
<div
|
||||
id="site-map"
|
||||
phx-hook="LeafletMap"
|
||||
data-lat={@site.latitude}
|
||||
data-lng={@site.longitude}
|
||||
data-zoom="14"
|
||||
data-marker-title={@site.name}
|
||||
class="h-48 w-full rounded-lg border border-gray-200 dark:border-white/10 mb-6 z-0"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{@site.location}</:subtitle>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue