ui: major redesign - NOC dashboard, consolidated nav, dense tables, flat alerts

This commit is contained in:
Graham McIntire 2026-02-14 20:14:52 -06:00
parent c7ba420ff1
commit 02474c529f
22 changed files with 2731 additions and 1222 deletions

View file

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

View file

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

View 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

View 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

View 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

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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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