refactor: fix all credo strict issues, format all code, fix broken routes
- Fix broken route paths in dashboard (/discovery, /subscribers/trace, /config-changes) - Fix insights empty state settings link (org-scoped route) - Add underscores to all 86400 literals across 6 files - Alphabetize aliases in search.ex and agent_channel.ex - Extract changelog parser helper to reduce nesting - Extract dashboard impact calculation to reduce nesting - Refactor agent_channel config change detection (pattern match, extract function) - Combine double Enum.reject into single call in trace.ex - Fix line length in trace.ex search query - Replace length/1 > 0 with != [] in trace_live - Run mix format on all files
This commit is contained in:
parent
493b1e8001
commit
9e7ee5099d
33 changed files with 743 additions and 339 deletions
|
|
@ -65,14 +65,14 @@ defmodule Towerops.ActivityFeed do
|
|||
"""
|
||||
def count_by_type(organization_id) do
|
||||
# Count recent items (last 24h) per type for badge display
|
||||
since = DateTime.add(DateTime.utc_now(), -86400, :second)
|
||||
since = DateTime.add(DateTime.utc_now(), -86_400, :second)
|
||||
|
||||
active_sources(nil)
|
||||
|> Enum.map(fn source ->
|
||||
nil
|
||||
|> active_sources()
|
||||
|> Map.new(fn source ->
|
||||
items = fetch_source(source, organization_id, since, nil, 1000)
|
||||
{source, length(items)}
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp maybe_filter_search(items, nil), do: items
|
||||
|
|
@ -101,8 +101,7 @@ defmodule Towerops.ActivityFeed do
|
|||
String.contains?(searchable, search)
|
||||
end
|
||||
|
||||
defp active_sources(nil),
|
||||
do: [:config_change, :alert_fired, :alert_resolved, :device_event, :sync, :device_added]
|
||||
defp active_sources(nil), do: [:config_change, :alert_fired, :alert_resolved, :device_event, :sync, :device_added]
|
||||
|
||||
defp active_sources(types) when is_list(types), do: types
|
||||
|
||||
|
|
@ -319,7 +318,8 @@ defmodule Towerops.ActivityFeed do
|
|||
device_name: nil,
|
||||
site_name: nil,
|
||||
summary: "Preseem sync #{status_label}",
|
||||
detail: "#{row.records_synced || 0} records synced#{if row.duration_ms, do: " in #{row.duration_ms}ms", else: ""}",
|
||||
detail:
|
||||
"#{row.records_synced || 0} records synced#{if row.duration_ms, do: " in #{row.duration_ms}ms", else: ""}",
|
||||
severity: sync_severity(row.status),
|
||||
icon: "hero-arrow-path",
|
||||
link: nil
|
||||
|
|
@ -401,8 +401,7 @@ defmodule Towerops.ActivityFeed do
|
|||
|
||||
defp format_gaiia_impact(nil), do: nil
|
||||
|
||||
defp format_gaiia_impact(%{"total_subscribers" => subs, "total_mrr" => mrr})
|
||||
when is_integer(subs) and subs > 0 do
|
||||
defp format_gaiia_impact(%{"total_subscribers" => subs, "total_mrr" => mrr}) when is_integer(subs) and subs > 0 do
|
||||
"Affects #{subs} subscribers, $#{mrr}/mo MRR at risk"
|
||||
end
|
||||
|
||||
|
|
@ -417,8 +416,8 @@ defmodule Towerops.ActivityFeed do
|
|||
cond do
|
||||
diff < 60 -> "#{diff}s"
|
||||
diff < 3600 -> "#{div(diff, 60)}m"
|
||||
diff < 86400 -> "#{div(diff, 3600)}h #{div(rem(diff, 3600), 60)}m"
|
||||
true -> "#{div(diff, 86400)}d #{div(rem(diff, 86400), 3600)}h"
|
||||
diff < 86_400 -> "#{div(diff, 3600)}h #{div(rem(diff, 3600), 60)}m"
|
||||
true -> "#{div(diff, 86_400)}d #{div(rem(diff, 86_400), 3600)}h"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,8 @@ defmodule Towerops.ConfigChanges do
|
|||
preloads = Keyword.get(opts, :preload, [:device])
|
||||
|
||||
device_ids =
|
||||
Towerops.Devices.list_site_devices(site_id)
|
||||
site_id
|
||||
|> Towerops.Devices.list_site_devices()
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
if device_ids == [] do
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ defmodule Towerops.ConfigChanges.ConfigChangeEvent do
|
|||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Devices.MikrotikBackup
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
|
|
@ -17,8 +19,8 @@ defmodule Towerops.ConfigChanges.ConfigChangeEvent do
|
|||
|
||||
belongs_to :device, Towerops.Devices.Device
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
belongs_to :backup_before, Towerops.Devices.MikrotikBackup
|
||||
belongs_to :backup_after, Towerops.Devices.MikrotikBackup
|
||||
belongs_to :backup_before, MikrotikBackup
|
||||
belongs_to :backup_after, MikrotikBackup
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -167,8 +167,6 @@ defmodule Towerops.Dashboard do
|
|||
duration_seconds =
|
||||
if device.last_status_change_at do
|
||||
DateTime.diff(now, device.last_status_change_at, :second)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%{
|
||||
|
|
@ -203,18 +201,7 @@ defmodule Towerops.Dashboard do
|
|||
down_devices = Devices.list_organization_devices(organization_id, %{"status" => "down", "site_id" => site.id})
|
||||
|
||||
{subscribers_affected, mrr_at_risk} =
|
||||
if down_count > 0 do
|
||||
impacts =
|
||||
Enum.map(down_devices, fn device ->
|
||||
ImpactAnalysis.analyze_device_impact(organization_id, device.id)
|
||||
end)
|
||||
|
||||
subs = impacts |> Enum.map(& &1.total_subscribers) |> Enum.sum()
|
||||
mrr = impacts |> Enum.map(& &1.total_mrr) |> Enum.reduce(Decimal.new("0"), &Decimal.add/2)
|
||||
{subs, mrr}
|
||||
else
|
||||
{0, Decimal.new("0")}
|
||||
end
|
||||
calculate_down_impact(organization_id, down_devices, down_count)
|
||||
|
||||
# Get average QoE from Preseem for devices at this site
|
||||
site_devices = Devices.list_site_devices(site.id)
|
||||
|
|
@ -269,4 +256,17 @@ defmodule Towerops.Dashboard do
|
|||
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
|
||||
}
|
||||
end
|
||||
|
||||
defp calculate_down_impact(_organization_id, _down_devices, 0), do: {0, Decimal.new("0")}
|
||||
|
||||
defp calculate_down_impact(organization_id, down_devices, _down_count) do
|
||||
impacts =
|
||||
Enum.map(down_devices, fn device ->
|
||||
ImpactAnalysis.analyze_device_impact(organization_id, device.id)
|
||||
end)
|
||||
|
||||
subs = impacts |> Enum.map(& &1.total_subscribers) |> Enum.sum()
|
||||
mrr = impacts |> Enum.map(& &1.total_mrr) |> Enum.reduce(Decimal.new("0"), &Decimal.add/2)
|
||||
{subs, mrr}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -110,14 +110,14 @@ defmodule Towerops.Preseem do
|
|||
capacity_scores = aps |> Enum.map(& &1.capacity_score) |> Enum.reject(&is_nil/1)
|
||||
|
||||
avg_qoe =
|
||||
if qoe_scores != [],
|
||||
do: Float.round(Enum.sum(qoe_scores) / length(qoe_scores), 1),
|
||||
else: nil
|
||||
if qoe_scores == [],
|
||||
do: nil,
|
||||
else: Float.round(Enum.sum(qoe_scores) / length(qoe_scores), 1)
|
||||
|
||||
avg_capacity =
|
||||
if capacity_scores != [],
|
||||
do: Float.round(Enum.sum(capacity_scores) / length(capacity_scores), 1),
|
||||
else: nil
|
||||
if capacity_scores == [],
|
||||
do: nil,
|
||||
else: Float.round(Enum.sum(capacity_scores) / length(capacity_scores), 1)
|
||||
|
||||
total_subscribers =
|
||||
aps |> Enum.map(& &1.subscriber_count) |> Enum.reject(&is_nil/1) |> Enum.sum()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ defmodule Towerops.Search do
|
|||
"""
|
||||
|
||||
import Ecto.Query
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Sites.Site
|
||||
alias Towerops.Gaiia.Account
|
||||
|
||||
alias Towerops.Alerts.Alert
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Gaiia.Account
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
@limit 5
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ defmodule Towerops.Search do
|
|||
|> where([a, d], d.organization_id == ^organization_id)
|
||||
|> where([a, _d], ilike(a.message, ^pattern))
|
||||
|> where([a, _d], is_nil(a.resolved_at))
|
||||
|> order_by([a, _d], [desc: a.triggered_at])
|
||||
|> order_by([a, _d], desc: a.triggered_at)
|
||||
|> limit(@limit)
|
||||
|> select([a, d], %{id: a.id, message: a.message, device_name: d.name})
|
||||
|> Repo.all()
|
||||
|
|
|
|||
|
|
@ -41,8 +41,7 @@ defmodule Towerops.Trace do
|
|||
devices = search_devices(organization_id, pattern)
|
||||
access_points = search_access_points(organization_id, pattern)
|
||||
|
||||
(accounts ++ inventory ++ devices ++ access_points)
|
||||
|> Enum.take(25)
|
||||
Enum.take(accounts ++ inventory ++ devices ++ access_points, 25)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -63,7 +62,11 @@ defmodule Towerops.Trace do
|
|||
defp search_inventory(organization_id, pattern) do
|
||||
InventoryItem
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([i], ilike(i.ip_address, ^pattern) or ilike(i.serial_number, ^pattern) or ilike(i.name, ^pattern) or ilike(i.mac_address, ^pattern))
|
||||
|> where(
|
||||
[i],
|
||||
ilike(i.ip_address, ^pattern) or ilike(i.serial_number, ^pattern) or
|
||||
ilike(i.name, ^pattern) or ilike(i.mac_address, ^pattern)
|
||||
)
|
||||
|> limit(10)
|
||||
|> preload(:device)
|
||||
|> Repo.all()
|
||||
|
|
@ -97,7 +100,8 @@ defmodule Towerops.Trace do
|
|||
|> preload(:device)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn ap ->
|
||||
sublabel = Enum.join(Enum.reject([ap.ip_address, ap.model, "#{ap.subscriber_count || 0} subscribers"], &is_nil/1), " · ")
|
||||
sublabel =
|
||||
Enum.join(Enum.reject([ap.ip_address, ap.model, "#{ap.subscriber_count || 0} subscribers"], &is_nil/1), " · ")
|
||||
|
||||
%{type: :access_point, id: ap.id, label: ap.name, sublabel: sublabel, data: ap}
|
||||
end)
|
||||
|
|
@ -123,7 +127,7 @@ defmodule Towerops.Trace do
|
|||
|
||||
defp trace_from_account(organization_id, account_id) do
|
||||
account = Repo.get_by(Account, id: account_id, organization_id: organization_id)
|
||||
unless account, do: throw(:not_found)
|
||||
if !account, do: throw(:not_found)
|
||||
|
||||
subscriptions = list_account_subscriptions(organization_id, account.gaiia_id)
|
||||
inventory_items = list_account_inventory(organization_id, account.gaiia_id)
|
||||
|
|
@ -143,7 +147,7 @@ defmodule Towerops.Trace do
|
|||
|> preload(:device)
|
||||
|> Repo.one()
|
||||
|
||||
unless item, do: throw(:not_found)
|
||||
if !item, do: throw(:not_found)
|
||||
|
||||
# Find account if linked
|
||||
account =
|
||||
|
|
@ -154,7 +158,7 @@ defmodule Towerops.Trace do
|
|||
subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: []
|
||||
inventory_items = if account, do: list_account_inventory(organization_id, account.gaiia_id), else: [item]
|
||||
|
||||
device = if item.device_id, do: Repo.get(Device, item.device_id) |> Repo.preload(:site)
|
||||
device = if item.device_id, do: Device |> Repo.get(item.device_id) |> Repo.preload(:site)
|
||||
access_point = if device, do: Repo.get_by(AccessPoint, device_id: device.id)
|
||||
|
||||
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
|
||||
|
|
@ -169,7 +173,7 @@ defmodule Towerops.Trace do
|
|||
|> preload(:site)
|
||||
|> Repo.one()
|
||||
|
||||
unless device, do: throw(:not_found)
|
||||
if !device, do: throw(:not_found)
|
||||
|
||||
access_point = Repo.get_by(AccessPoint, device_id: device.id)
|
||||
|
||||
|
|
@ -183,7 +187,9 @@ defmodule Towerops.Trace do
|
|||
case inventory_items do
|
||||
[first | _] when not is_nil(first.assigned_account_gaiia_id) ->
|
||||
Repo.get_by(Account, organization_id: organization_id, gaiia_id: first.assigned_account_gaiia_id)
|
||||
_ -> nil
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: []
|
||||
|
|
@ -200,9 +206,9 @@ defmodule Towerops.Trace do
|
|||
|> preload(:device)
|
||||
|> Repo.one()
|
||||
|
||||
unless access_point, do: throw(:not_found)
|
||||
if !access_point, do: throw(:not_found)
|
||||
|
||||
device = if access_point.device_id, do: access_point.device |> Repo.preload(:site)
|
||||
device = if access_point.device_id, do: Repo.preload(access_point.device, :site)
|
||||
|
||||
build_trace(nil, [], [], device, access_point, organization_id)
|
||||
catch
|
||||
|
|
@ -321,7 +327,7 @@ defmodule Towerops.Trace do
|
|||
defp load_recent_config_changes(nil), do: []
|
||||
|
||||
defp load_recent_config_changes(device_id) do
|
||||
since = DateTime.utc_now() |> DateTime.add(-24 * 3600, :second)
|
||||
since = DateTime.add(DateTime.utc_now(), -24 * 3600, :second)
|
||||
|
||||
ConfigChangeEvent
|
||||
|> where(device_id: ^device_id)
|
||||
|
|
@ -351,7 +357,7 @@ defmodule Towerops.Trace do
|
|||
item_with_device = Enum.find(inventory_items, & &1.device_id)
|
||||
|
||||
if item_with_device do
|
||||
device = Repo.get(Device, item_with_device.device_id) |> Repo.preload(:site)
|
||||
device = Device |> Repo.get(item_with_device.device_id) |> Repo.preload(:site)
|
||||
access_point = Repo.get_by(AccessPoint, device_id: device.id)
|
||||
{device, access_point}
|
||||
else
|
||||
|
|
@ -377,15 +383,16 @@ defmodule Towerops.Trace do
|
|||
|
||||
defp format_address(addr) when is_map(addr) do
|
||||
parts =
|
||||
[
|
||||
addr["street1"] || addr["line1"],
|
||||
addr["street2"] || addr["line2"],
|
||||
addr["city"],
|
||||
addr["state"] || addr["province"],
|
||||
addr["zip"] || addr["postal_code"]
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.reject(&(String.trim(&1) == ""))
|
||||
Enum.reject(
|
||||
[
|
||||
addr["street1"] || addr["line1"],
|
||||
addr["street2"] || addr["line2"],
|
||||
addr["city"],
|
||||
addr["state"] || addr["province"],
|
||||
addr["zip"] || addr["postal_code"]
|
||||
],
|
||||
&(is_nil(&1) or String.trim(&1) == "")
|
||||
)
|
||||
|
||||
if parts == [], do: nil, else: Enum.join(parts, ", ")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ defmodule ToweropsWeb do
|
|||
use Gettext, backend: ToweropsWeb.Gettext
|
||||
|
||||
import Phoenix.HTML
|
||||
import ToweropsWeb.CoreComponents
|
||||
import ToweropsWeb.Components.Breadcrumbs
|
||||
import ToweropsWeb.Components.Skeletons
|
||||
import ToweropsWeb.CoreComponents
|
||||
import ToweropsWeb.GettextHelpers
|
||||
|
||||
# HTML escaping functionality
|
||||
|
|
|
|||
|
|
@ -30,14 +30,7 @@ defmodule ToweropsWeb.ChangelogParser do
|
|||
|
||||
# Bullet point
|
||||
String.starts_with?(String.trim(line), "* ") ->
|
||||
case acc do
|
||||
[current | rest] ->
|
||||
item = String.trim(line) |> String.trim_leading("* ")
|
||||
[%{current | items: current.items ++ [item]} | rest]
|
||||
|
||||
[] ->
|
||||
acc
|
||||
end
|
||||
add_item_to_current(acc, line |> String.trim() |> String.trim_leading("* "))
|
||||
|
||||
true ->
|
||||
acc
|
||||
|
|
@ -45,4 +38,8 @@ defmodule ToweropsWeb.ChangelogParser do
|
|||
end)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp add_item_to_current([current | rest], item), do: [%{current | items: current.items ++ [item]} | rest]
|
||||
|
||||
defp add_item_to_current([], _item), do: []
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
alias Towerops.Agent.Validator
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.ConfigChanges
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Devices.BackupRequests
|
||||
alias Towerops.Devices.MikrotikBackups
|
||||
|
|
@ -1821,29 +1822,31 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
|
||||
case backups do
|
||||
[_new, previous | _] ->
|
||||
if previous.config_hash_normalized != new_backup.config_hash_normalized do
|
||||
case Towerops.ConfigChanges.record_change_event(device_id, previous, new_backup) do
|
||||
{:ok, %Towerops.ConfigChanges.ConfigChangeEvent{} = event} ->
|
||||
Logger.info("Config change event recorded", device_id: device_id, event_id: event.id)
|
||||
|
||||
# Async correlate with performance metrics
|
||||
Task.start(fn ->
|
||||
Towerops.ConfigChanges.Correlator.correlate(event)
|
||||
end)
|
||||
|
||||
{:ok, :no_changes} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to record config change event: #{inspect(reason)}", device_id: device_id)
|
||||
end
|
||||
end
|
||||
maybe_record_change(device_id, previous, new_backup)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_record_change(_device_id, %{config_hash_normalized: hash}, %{config_hash_normalized: hash}), do: :ok
|
||||
|
||||
defp maybe_record_change(device_id, previous, new_backup) do
|
||||
case ConfigChanges.record_change_event(device_id, previous, new_backup) do
|
||||
{:ok, %{id: event_id} = event} ->
|
||||
Logger.info("Config change event recorded", device_id: device_id, event_id: event_id)
|
||||
Task.start(fn -> ConfigChanges.Correlator.correlate(event) end)
|
||||
|
||||
{:ok, :no_changes} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to record config change event: #{inspect(reason)}",
|
||||
device_id: device_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp backup_needed?(device_id, config_text) do
|
||||
{:ok, needs_backup?} = MikrotikBackups.needs_backup?(device_id, config_text)
|
||||
needs_backup?
|
||||
|
|
|
|||
|
|
@ -4,16 +4,25 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<meta name="description" content="TowerOps — Operational intelligence for WISP and ISP operators. Connect network monitoring to subscriber impact and revenue." />
|
||||
<meta
|
||||
name="description"
|
||||
content="TowerOps — Operational intelligence for WISP and ISP operators. Connect network monitoring to subscriber impact and revenue."
|
||||
/>
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="TowerOps" />
|
||||
<meta property="og:title" content="TowerOps — See the Business Impact of Every Network Event" />
|
||||
<meta property="og:description" content="The only platform that connects network monitoring to subscriber impact and revenue. Built for WISP and ISP operators." />
|
||||
|
||||
<!-- Favicon -->
|
||||
<meta
|
||||
property="og:title"
|
||||
content="TowerOps — See the Business Impact of Every Network Event"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="The only platform that connects network monitoring to subscriber impact and revenue. Built for WISP and ISP operators."
|
||||
/>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href={~p"/images/towerops_logo.png"} />
|
||||
<link rel="apple-touch-icon" href={~p"/images/towerops_logo.png"} />
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ defmodule ToweropsWeb.Components.Skeletons do
|
|||
|
||||
def skeleton_card(assigns) do
|
||||
~H"""
|
||||
<div class={["animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50", @class]}>
|
||||
<div class={[
|
||||
"animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50",
|
||||
@class
|
||||
]}>
|
||||
<div class="h-3 w-24 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-3 h-8 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
|
@ -33,7 +36,10 @@ defmodule ToweropsWeb.Components.Skeletons do
|
|||
<div :for={_ <- 1..@cols} class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div :for={_ <- 1..@rows} class="border-b border-gray-100 px-4 py-3 last:border-0 dark:border-white/5">
|
||||
<div
|
||||
:for={_ <- 1..@rows}
|
||||
class="border-b border-gray-100 px-4 py-3 last:border-0 dark:border-white/5"
|
||||
>
|
||||
<div class="flex gap-6">
|
||||
<div :for={_ <- 1..@cols} class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
|
@ -49,7 +55,10 @@ defmodule ToweropsWeb.Components.Skeletons do
|
|||
|
||||
def skeleton_stat(assigns) do
|
||||
~H"""
|
||||
<div class={["animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50", @class]}>
|
||||
<div class={[
|
||||
"animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50",
|
||||
@class
|
||||
]}>
|
||||
<div class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-8 w-12 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
|
@ -72,7 +81,10 @@ defmodule ToweropsWeb.Components.Skeletons do
|
|||
<div class="lg:col-span-3">
|
||||
<div class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700 mb-4"></div>
|
||||
<div class="space-y-3">
|
||||
<div :for={_ <- 1..3} class="animate-pulse rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-800/50">
|
||||
<div
|
||||
:for={_ <- 1..3}
|
||||
class="animate-pulse rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="h-3 w-32 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-4 w-48 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-3 w-64 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
|
@ -82,7 +94,10 @@ defmodule ToweropsWeb.Components.Skeletons do
|
|||
<div class="lg:col-span-2">
|
||||
<div class="h-5 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-700 mb-4"></div>
|
||||
<div class="space-y-3">
|
||||
<div :for={_ <- 1..3} class="animate-pulse rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50">
|
||||
<div
|
||||
:for={_ <- 1..3}
|
||||
class="animate-pulse rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="h-3 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-4 w-40 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pain Point Section -->
|
||||
|
||||
<!-- Pain Point Section -->
|
||||
<section class="bg-slate-900 py-20 sm:py-24">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
|
|
@ -81,8 +81,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Primary Features Section -->
|
||||
|
||||
<!-- Primary Features Section -->
|
||||
<section
|
||||
id="features"
|
||||
aria-label="Features for WISP operators"
|
||||
|
|
@ -179,8 +179,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works Section -->
|
||||
|
||||
<!-- How It Works Section -->
|
||||
<section
|
||||
id="how-it-works"
|
||||
aria-label="How TowerOps works"
|
||||
|
|
@ -225,11 +225,21 @@
|
|||
TowerOps automatically maps subscribers to devices and tower sites.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">Gaiia</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">Sonar</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">Preseem</span>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 ring-1 ring-slate-300">UISP — coming soon</span>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 ring-1 ring-slate-300">Splynx — coming soon</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">
|
||||
Gaiia
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">
|
||||
Sonar
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">
|
||||
Preseem
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 ring-1 ring-slate-300">
|
||||
UISP — coming soon
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 ring-1 ring-slate-300">
|
||||
Splynx — coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 3 -->
|
||||
|
|
@ -260,8 +270,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Social Proof / Use Cases -->
|
||||
|
||||
<!-- Social Proof / Use Cases -->
|
||||
<section class="py-20 sm:py-24">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl md:text-center">
|
||||
|
|
@ -300,8 +310,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing Section -->
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<section id="pricing" class="bg-slate-50 py-20 sm:py-32">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
|
|
@ -447,12 +457,15 @@
|
|||
</div>
|
||||
<p class="mt-8 text-center text-sm text-slate-500">
|
||||
All plans include team access, email alerts, and API access.
|
||||
Need a custom plan for 500+ devices? <a href="mailto:hello@towerops.net" class="text-blue-600 hover:text-blue-500 underline">Talk to us</a>.
|
||||
Need a custom plan for 500+ devices? <a
|
||||
href="mailto:hello@towerops.net"
|
||||
class="text-blue-600 hover:text-blue-500 underline"
|
||||
>Talk to us</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tech Details -->
|
||||
|
||||
<!-- Tech Details -->
|
||||
<section class="py-20 sm:py-24">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl md:text-center">
|
||||
|
|
@ -491,8 +504,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Final CTA -->
|
||||
|
||||
<!-- Final CTA -->
|
||||
<section class="relative overflow-hidden bg-blue-600 py-32">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 relative">
|
||||
<div class="mx-auto max-w-lg text-center">
|
||||
|
|
|
|||
|
|
@ -233,9 +233,9 @@ defmodule ToweropsWeb.ActivityFeedLive do
|
|||
diff < 5 -> "just now"
|
||||
diff < 60 -> "#{diff}s ago"
|
||||
diff < 3600 -> "#{div(diff, 60)}m ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)}h ago"
|
||||
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
||||
diff < 172_800 -> "yesterday"
|
||||
diff < 604_800 -> "#{div(diff, 86400)}d ago"
|
||||
diff < 604_800 -> "#{div(diff, 86_400)}d ago"
|
||||
true -> Calendar.strftime(timestamp, "%b %d, %Y")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,19 +6,25 @@
|
|||
<.header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
|
||||
</span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
Activity Feed
|
||||
</div>
|
||||
<:subtitle>Real-time NOC operations log — config changes, alerts, events, and syncs</:subtitle>
|
||||
<:subtitle>
|
||||
Real-time NOC operations log — config changes, alerts, events, and syncs
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%!-- Search Bar --%>
|
||||
<div class="mt-6">
|
||||
<form phx-change="search" phx-submit="search" class="relative">
|
||||
<div class="relative">
|
||||
<.icon name="hero-magnifying-glass" class="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
<.icon
|
||||
name="hero-magnifying-glass"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
|
|
@ -51,7 +57,8 @@
|
|||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
|
||||
if(type in @active_types,
|
||||
do: filter_active_class(type),
|
||||
else: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
else:
|
||||
"bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
>
|
||||
|
|
@ -82,7 +89,7 @@
|
|||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<%= if @search != "" do %>
|
||||
No results for "<%= @search %>"
|
||||
No results for "{@search}"
|
||||
<% else %>
|
||||
No activity yet
|
||||
<% end %>
|
||||
|
|
@ -100,8 +107,7 @@
|
|||
phx-click="clear_search"
|
||||
class="btn btn-sm btn-outline gap-2"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||
Clear search
|
||||
<.icon name="hero-x-mark" class="h-4 w-4" /> Clear search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -110,11 +116,14 @@
|
|||
<div class="mb-4 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Showing {length(@items)} events
|
||||
<span :if={@search != ""}>matching "<strong class="text-gray-700 dark:text-gray-300">{@search}</strong>"</span>
|
||||
<span :if={@search != ""}>
|
||||
matching "<strong class="text-gray-700 dark:text-gray-300">{@search}</strong>"
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
|
||||
</span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live
|
||||
|
|
@ -127,7 +136,9 @@
|
|||
<div class="h-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
|
||||
<span class="flex-shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 px-3 py-1 rounded-full">
|
||||
{period}
|
||||
<span class="ml-1 text-gray-400 dark:text-gray-500 font-normal">({length(period_items)})</span>
|
||||
<span class="ml-1 text-gray-400 dark:text-gray-500 font-normal">
|
||||
({length(period_items)})
|
||||
</span>
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
|
@ -156,8 +167,17 @@
|
|||
<%!-- Content --%>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<.icon name={item.icon} class={["h-4 w-4 flex-shrink-0", severity_icon_color(item.severity, item.type)]} />
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 tabular-nums" title={Calendar.strftime(item.timestamp, "%Y-%m-%d %H:%M:%S UTC")}>
|
||||
<.icon
|
||||
name={item.icon}
|
||||
class={[
|
||||
"h-4 w-4 flex-shrink-0",
|
||||
severity_icon_color(item.severity, item.type)
|
||||
]}
|
||||
/>
|
||||
<span
|
||||
class="text-xs text-gray-500 dark:text-gray-400 tabular-nums"
|
||||
title={Calendar.strftime(item.timestamp, "%Y-%m-%d %H:%M:%S UTC")}
|
||||
>
|
||||
{relative_time(item.timestamp, @now)}
|
||||
</span>
|
||||
<span class={[
|
||||
|
|
@ -184,7 +204,10 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<p class={["mt-0.5 text-sm font-medium", severity_text_color(item.severity, item.type)]}>
|
||||
<p class={[
|
||||
"mt-0.5 text-sm font-medium",
|
||||
severity_text_color(item.severity, item.type)
|
||||
]}>
|
||||
<%= if item.link do %>
|
||||
<.link navigate={item.link} class="hover:underline">
|
||||
{item.summary}
|
||||
|
|
@ -194,19 +217,31 @@
|
|||
<% end %>
|
||||
</p>
|
||||
|
||||
<p :if={item.detail && item.detail != ""} class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p
|
||||
:if={item.detail && item.detail != ""}
|
||||
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{item.detail}
|
||||
</p>
|
||||
|
||||
<div :if={item.site_name} class="mt-0.5 inline-flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div
|
||||
:if={item.site_name}
|
||||
class="mt-0.5 inline-flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<.icon name="hero-map-pin" class="h-3 w-3" />
|
||||
{item.site_name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Link arrow --%>
|
||||
<div :if={item.link} class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<.link navigate={item.link} class="text-gray-400 hover:text-blue-500 dark:text-gray-600 dark:hover:text-blue-400">
|
||||
<div
|
||||
:if={item.link}
|
||||
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<.link
|
||||
navigate={item.link}
|
||||
class="text-gray-400 hover:text-blue-500 dark:text-gray-600 dark:hover:text-blue-400"
|
||||
>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4" />
|
||||
</.link>
|
||||
</div>
|
||||
|
|
@ -223,8 +258,7 @@
|
|||
phx-click="load_more"
|
||||
class="btn btn-outline btn-sm gap-2"
|
||||
>
|
||||
<.icon name="hero-arrow-down" class="h-4 w-4" />
|
||||
Load more events
|
||||
<.icon name="hero-arrow-down" class="h-4 w-4" /> Load more events
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -145,11 +145,9 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
defp filter_alerts(alerts, "critical"),
|
||||
do: Enum.filter(alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at)))
|
||||
|
||||
defp filter_alerts(alerts, "unresolved"),
|
||||
do: Enum.filter(alerts, &is_nil(&1.resolved_at))
|
||||
defp filter_alerts(alerts, "unresolved"), do: Enum.filter(alerts, &is_nil(&1.resolved_at))
|
||||
|
||||
defp filter_alerts(alerts, "resolved"),
|
||||
do: Enum.filter(alerts, &(not is_nil(&1.resolved_at)))
|
||||
defp filter_alerts(alerts, "resolved"), do: Enum.filter(alerts, &(not is_nil(&1.resolved_at)))
|
||||
|
||||
defp filter_alerts(alerts, _), do: alerts
|
||||
|
||||
|
|
@ -188,7 +186,7 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
end
|
||||
end)
|
||||
|> Enum.map(fn {{site_id, site_name}, site_alerts} ->
|
||||
sub_info = if site_id, do: site_subscribers[site_id], else: nil
|
||||
sub_info = if site_id, do: site_subscribers[site_id]
|
||||
critical_count = Enum.count(site_alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at)))
|
||||
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
Alerts
|
||||
<span class="badge badge-info badge-sm">Experimental</span>
|
||||
Alerts <span class="badge badge-info badge-sm">Experimental</span>
|
||||
</span>
|
||||
<:subtitle>Triage alerts by site impact — fix the biggest problems first</:subtitle>
|
||||
</.header>
|
||||
|
|
@ -17,29 +16,25 @@
|
|||
patch={~p"/alerts?filter=unresolved&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "unresolved" && "tab-active"]}
|
||||
>
|
||||
Unresolved
|
||||
<span class="badge badge-sm ml-1">{@counts.unresolved}</span>
|
||||
Unresolved <span class="badge badge-sm ml-1">{@counts.unresolved}</span>
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=critical&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "critical" && "tab-active"]}
|
||||
>
|
||||
Critical
|
||||
<span class="badge badge-error badge-sm ml-1">{@counts.critical}</span>
|
||||
Critical <span class="badge badge-error badge-sm ml-1">{@counts.critical}</span>
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=all&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "all" && "tab-active"]}
|
||||
>
|
||||
All
|
||||
<span class="badge badge-sm ml-1">{@counts.all}</span>
|
||||
All <span class="badge badge-sm ml-1">{@counts.all}</span>
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=resolved&sort=#{@sort_by}"}
|
||||
class={["tab", @filter == "resolved" && "tab-active"]}
|
||||
>
|
||||
Resolved
|
||||
<span class="badge badge-ghost badge-sm ml-1">{@counts.resolved}</span>
|
||||
Resolved <span class="badge badge-ghost badge-sm ml-1">{@counts.resolved}</span>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
|
|
@ -48,19 +43,19 @@
|
|||
<span class="text-gray-500 dark:text-gray-400 font-medium">Sort:</span>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=#{@filter}&sort=severity"}
|
||||
class={["btn btn-xs", @sort_by == "severity" && "btn-primary" || "btn-ghost"]}
|
||||
class={["btn btn-xs", (@sort_by == "severity" && "btn-primary") || "btn-ghost"]}
|
||||
>
|
||||
Severity
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=#{@filter}&sort=age"}
|
||||
class={["btn btn-xs", @sort_by == "age" && "btn-primary" || "btn-ghost"]}
|
||||
class={["btn btn-xs", (@sort_by == "age" && "btn-primary") || "btn-ghost"]}
|
||||
>
|
||||
Oldest First
|
||||
</.link>
|
||||
<.link
|
||||
patch={~p"/alerts?filter=#{@filter}&sort=impact"}
|
||||
class={["btn btn-xs", @sort_by == "impact" && "btn-primary" || "btn-ghost"]}
|
||||
class={["btn btn-xs", (@sort_by == "impact" && "btn-primary") || "btn-ghost"]}
|
||||
>
|
||||
Subscriber Impact
|
||||
</.link>
|
||||
|
|
@ -71,7 +66,10 @@
|
|||
<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" />
|
||||
<.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">All Clear!</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
|
|
@ -95,12 +93,15 @@
|
|||
<%= 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-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}
|
||||
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"}
|
||||
/>
|
||||
|
|
@ -110,7 +111,11 @@
|
|||
<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"}
|
||||
name={
|
||||
if group.critical_count > 0,
|
||||
do: "hero-exclamation-triangle",
|
||||
else: "hero-signal"
|
||||
}
|
||||
class={[
|
||||
"h-5 w-5",
|
||||
group.critical_count > 0 && "text-red-500",
|
||||
|
|
@ -171,18 +176,24 @@
|
|||
<% 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"
|
||||
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 && is_nil(alert.resolved_at) &&
|
||||
"badge-error",
|
||||
alert.alert_type == :device_down && alert.resolved_at && "badge-ghost",
|
||||
alert.alert_type == :device_up && "badge-success"
|
||||
]}>
|
||||
|
|
@ -234,7 +245,9 @@
|
|||
>
|
||||
{alert.device.name}
|
||||
</.link>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">{alert.device.ip_address}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
||||
{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>
|
||||
|
|
@ -243,11 +256,17 @@
|
|||
<%!-- 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)}
|
||||
Triggered: {ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.triggered_at,
|
||||
@timezone
|
||||
)}
|
||||
</span>
|
||||
<%= if alert.acknowledged_at do %>
|
||||
<span>
|
||||
Ack'd: {ToweropsWeb.TimeHelpers.format_iso8601(alert.acknowledged_at, @timezone)}
|
||||
Ack'd: {ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.acknowledged_at,
|
||||
@timezone
|
||||
)}
|
||||
<%= if alert.acknowledged_by do %>
|
||||
by {alert.acknowledged_by.email}
|
||||
<% end %>
|
||||
|
|
@ -255,7 +274,10 @@
|
|||
<% end %>
|
||||
<%= if alert.resolved_at do %>
|
||||
<span>
|
||||
Resolved: {ToweropsWeb.TimeHelpers.format_iso8601(alert.resolved_at, @timezone)}
|
||||
Resolved: {ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
alert.resolved_at,
|
||||
@timezone
|
||||
)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
defmodule ToweropsWeb.ChangelogLive do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
@ -23,7 +24,10 @@ defmodule ToweropsWeb.ChangelogLive do
|
|||
</span>
|
||||
</div>
|
||||
<h2 :if={entry.title} class="text-lg font-semibold mb-2">{entry.title}</h2>
|
||||
<ul :if={entry.items != []} class="list-disc list-inside space-y-1 text-base-content/80 text-sm">
|
||||
<ul
|
||||
:if={entry.items != []}
|
||||
class="list-disc list-inside space-y-1 text-base-content/80 text-sm"
|
||||
>
|
||||
<li :for={item <- entry.items}>{item}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
|||
@impl true
|
||||
def handle_event("keydown", %{"key" => "Enter"}, socket) do
|
||||
flat = flat_results(socket.assigns.results)
|
||||
|
||||
case Enum.at(flat, socket.assigns.selected_index) do
|
||||
nil -> {:noreply, socket}
|
||||
result -> {:noreply, push_navigate(socket, to: result.url)}
|
||||
|
|
@ -75,7 +76,12 @@ defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
|||
<div id="global-search" phx-hook="GlobalSearch" phx-target={@myself}>
|
||||
<%= if @open do %>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" phx-click="close" phx-target={@myself}></div>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 transition-opacity"
|
||||
phx-click="close"
|
||||
phx-target={@myself}
|
||||
>
|
||||
</div>
|
||||
<div class="flex min-h-full items-start justify-center p-4 pt-[15vh]">
|
||||
<div class="relative w-full max-w-lg transform rounded-xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
|
||||
<div class="flex items-center gap-3 border-b border-gray-200 px-4 dark:border-white/10">
|
||||
|
|
@ -93,12 +99,16 @@ defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
|||
autocomplete="off"
|
||||
class="flex-1 border-0 bg-transparent py-3.5 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<kbd class="hidden sm:inline-flex items-center rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600 dark:text-gray-500">esc</kbd>
|
||||
<kbd class="hidden sm:inline-flex items-center rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600 dark:text-gray-500">
|
||||
esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto px-2 py-2">
|
||||
<%= if @query != "" and map_size(@results) == 0 do %>
|
||||
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">No results found.</p>
|
||||
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No results found.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= for {category, items} <- @results do %>
|
||||
|
|
@ -114,14 +124,20 @@ defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
|||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-pointer",
|
||||
if(global_idx == @selected_index,
|
||||
do: "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
else: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
else:
|
||||
"text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<.icon name={category_icon(category)} class="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
<.icon
|
||||
name={category_icon(category)}
|
||||
class="h-4 w-4 flex-shrink-0 opacity-60"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium">{item.label}</p>
|
||||
<p :if={item.sublabel} class="truncate text-xs opacity-60">{item.sublabel}</p>
|
||||
<p :if={item.sublabel} class="truncate text-xs opacity-60">
|
||||
{item.sublabel}
|
||||
</p>
|
||||
</div>
|
||||
</.link>
|
||||
<% end %>
|
||||
|
|
@ -172,6 +188,7 @@ defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
|||
|
||||
defp global_index(results, category, local_idx) do
|
||||
order = [:devices, :sites, :accounts, :alerts]
|
||||
|
||||
offset =
|
||||
order
|
||||
|> Enum.take_while(&(&1 != category))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.ConfigChanges
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Preseem.AccessPoint
|
||||
|
|
@ -12,8 +14,6 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
alias Towerops.Repo
|
||||
alias ToweropsWeb.Live.Helpers.AccessControl
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@ranges %{
|
||||
"24h" => 24,
|
||||
"7d" => 24 * 7,
|
||||
|
|
@ -146,12 +146,20 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={event <- @change_events} class="hover:bg-base-200/50 cursor-pointer" phx-click="select_event" phx-value-id={event.id}>
|
||||
<tr
|
||||
:for={event <- @change_events}
|
||||
class="hover:bg-base-200/50 cursor-pointer"
|
||||
phx-click="select_event"
|
||||
phx-value-id={event.id}
|
||||
>
|
||||
<td class="whitespace-nowrap">
|
||||
{Calendar.strftime(event.changed_at, "%b %d, %H:%M UTC")}
|
||||
</td>
|
||||
<td>
|
||||
<span :for={section <- event.sections_changed} class="badge badge-sm badge-ghost mr-1">
|
||||
<span
|
||||
:for={section <- event.sections_changed}
|
||||
class="badge badge-sm badge-ghost mr-1"
|
||||
>
|
||||
{section}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -162,7 +170,9 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
</td>
|
||||
<td>
|
||||
<.link
|
||||
navigate={~p"/devices/#{@device.id}/backups/compare?before=#{event.backup_before_id}&after=#{event.backup_after_id}"}
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/backups/compare?before=#{event.backup_before_id}&after=#{event.backup_after_id}"
|
||||
}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
View Diff
|
||||
|
|
@ -191,10 +201,18 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
<div>
|
||||
<span class="text-sm font-medium text-base-content/60">Sections Changed</span>
|
||||
<div class="mt-1">
|
||||
<span :for={s <- @selected_event.sections_changed} class="badge badge-sm badge-outline mr-1">
|
||||
<span
|
||||
:for={s <- @selected_event.sections_changed}
|
||||
class="badge badge-sm badge-outline mr-1"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
<span :if={Enum.empty?(@selected_event.sections_changed)} class="text-base-content/40">Unknown</span>
|
||||
<span
|
||||
:if={Enum.empty?(@selected_event.sections_changed)}
|
||||
class="text-base-content/40"
|
||||
>
|
||||
Unknown
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -203,7 +221,9 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
</div>
|
||||
<div>
|
||||
<.link
|
||||
navigate={~p"/devices/#{@device.id}/backups/compare?before=#{@selected_event.backup_before_id}&after=#{@selected_event.backup_after_id}"}
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/backups/compare?before=#{@selected_event.backup_before_id}&after=#{@selected_event.backup_after_id}"
|
||||
}
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
Full Diff View →
|
||||
|
|
|
|||
|
|
@ -241,8 +241,8 @@ defmodule ToweropsWeb.DashboardLive do
|
|||
cond do
|
||||
seconds < 60 -> "#{seconds}s"
|
||||
seconds < 3600 -> "#{div(seconds, 60)}m"
|
||||
seconds < 86400 -> "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m"
|
||||
true -> "#{div(seconds, 86400)}d #{div(rem(seconds, 86400), 3600)}h"
|
||||
seconds < 86_400 -> "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m"
|
||||
true -> "#{div(seconds, 86_400)}d #{div(rem(seconds, 86_400), 3600)}h"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -279,14 +279,11 @@ 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 >= 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) 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 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)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@
|
|||
phx-click="refresh_dashboard"
|
||||
class="btn btn-ghost btn-xs gap-1"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
<.icon name="hero-arrow-path" class="h-3.5 w-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -130,7 +129,9 @@
|
|||
]}>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Network Uptime</h3>
|
||||
<p class={["mt-1 text-3xl font-bold", uptime_color(@uptime_percentage)]}>
|
||||
{if @uptime_percentage == 100.0, do: "100", else: :erlang.float_to_binary(@uptime_percentage, decimals: 1)}%
|
||||
{if @uptime_percentage == 100.0,
|
||||
do: "100",
|
||||
else: :erlang.float_to_binary(@uptime_percentage, decimals: 1)}%
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{@device_up}/{@device_count} devices up
|
||||
|
|
@ -205,7 +206,10 @@
|
|||
]}>
|
||||
{format_mrr(@impact_summary.mrr_at_risk)}
|
||||
</p>
|
||||
<p :if={@impact_summary.subscribers_affected > 0} class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
<p
|
||||
:if={@impact_summary.subscribers_affected > 0}
|
||||
class="mt-1 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{format_number(@impact_summary.subscribers_affected)} subscribers affected
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -268,8 +272,7 @@
|
|||
<div class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<.icon name="hero-fire" class="h-5 w-5 text-red-500" />
|
||||
Active Incidents
|
||||
<.icon name="hero-fire" class="h-5 w-5 text-red-500" /> Active Incidents
|
||||
<span class="ml-1 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
{length(@active_incidents)}
|
||||
</span>
|
||||
|
|
@ -278,7 +281,10 @@
|
|||
<div class="space-y-3">
|
||||
<div
|
||||
:for={incident <- Enum.take(@active_incidents, 10)}
|
||||
class={["rounded-lg border p-4 shadow-sm", incident_severity_classes(incident.mrr_at_risk)]}
|
||||
class={[
|
||||
"rounded-lg border p-4 shadow-sm",
|
||||
incident_severity_classes(incident.mrr_at_risk)
|
||||
]}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
@ -337,8 +343,7 @@
|
|||
<div class="lg:col-span-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<.icon name="hero-bell-alert" class="h-5 w-5 text-red-500" />
|
||||
Active Alerts
|
||||
<.icon name="hero-bell-alert" class="h-5 w-5 text-red-500" /> Active Alerts
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/alerts"}
|
||||
|
|
@ -473,7 +478,10 @@
|
|||
{if change.device, do: change.device.name, else: "Unknown Device"}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{Calendar.strftime(change.changed_at, "%b %d %H:%M")} · {change.change_size} lines · {Enum.join(Enum.take(change.sections_changed, 2), ", ")}
|
||||
{Calendar.strftime(change.changed_at, "%b %d %H:%M")} · {change.change_size} lines · {Enum.join(
|
||||
Enum.take(change.sections_changed, 2),
|
||||
", "
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</.link>
|
||||
|
|
@ -489,8 +497,7 @@
|
|||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
|
||||
<.icon name="hero-clock" class="h-4 w-4 text-blue-500" />
|
||||
Recent Activity
|
||||
<.icon name="hero-clock" class="h-4 w-4 text-blue-500" /> Recent Activity
|
||||
</h3>
|
||||
<.link
|
||||
navigate={~p"/activity"}
|
||||
|
|
@ -528,8 +535,7 @@
|
|||
<div class="lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<.icon name="hero-light-bulb" class="h-5 w-5 text-yellow-500" />
|
||||
Insights
|
||||
<.icon name="hero-light-bulb" class="h-5 w-5 text-yellow-500" /> Insights
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/insights"}
|
||||
|
|
@ -635,8 +641,7 @@
|
|||
<div class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<.icon name="hero-map-pin" class="h-5 w-5 text-blue-500" />
|
||||
Site Health
|
||||
<.icon name="hero-map-pin" class="h-5 w-5 text-blue-500" /> Site Health
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/sites"}
|
||||
|
|
@ -655,7 +660,11 @@
|
|||
]}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={["h-2.5 w-2.5 rounded-full flex-shrink-0", site_health_dot(site.site_health)]}></span>
|
||||
<span class={[
|
||||
"h-2.5 w-2.5 rounded-full flex-shrink-0",
|
||||
site_health_dot(site.site_health)
|
||||
]}>
|
||||
</span>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{site.name}</h3>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3 text-sm">
|
||||
|
|
|
|||
|
|
@ -450,8 +450,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
diff_seconds < 60 -> {"#{diff_seconds}s ago", "text-green-600 dark:text-green-400"}
|
||||
diff_seconds < 600 -> {"#{div(diff_seconds, 60)}m ago", "text-green-600 dark:text-green-400"}
|
||||
diff_seconds < 3600 -> {"#{div(diff_seconds, 60)}m ago", "text-yellow-600 dark:text-yellow-400"}
|
||||
diff_seconds < 86400 -> {"#{div(diff_seconds, 3600)}h ago", "text-red-600 dark:text-red-400"}
|
||||
true -> {"#{div(diff_seconds, 86400)}d ago", "text-red-600 dark:text-red-400"}
|
||||
diff_seconds < 86_400 -> {"#{div(diff_seconds, 3600)}h ago", "text-red-600 dark:text-red-400"}
|
||||
true -> {"#{div(diff_seconds, 86_400)}d ago", "text-red-600 dark:text-red-400"}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -185,17 +185,25 @@
|
|||
<% 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="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
|
||||
: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
|
||||
: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>
|
||||
|
|
@ -354,7 +362,13 @@
|
|||
data-device-position={row.device_index + 1}
|
||||
draggable={if @reorder_mode, do: "true", else: "false"}
|
||||
>
|
||||
<td :if={@reorder_mode} class={["pl-4 pr-2 sm:pl-3", if(@compact_mode, do: "py-1.5", else: "py-4")]}>
|
||||
<td
|
||||
:if={@reorder_mode}
|
||||
class={[
|
||||
"pl-4 pr-2 sm:pl-3",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
]}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
|
|
@ -387,7 +401,10 @@
|
|||
<td class="p-0 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono">
|
||||
<.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")]}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
]}
|
||||
>
|
||||
{row.device.ip_address}
|
||||
</.link>
|
||||
|
|
@ -395,7 +412,10 @@
|
|||
<td class="p-0 text-sm 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")]}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
]}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class={[
|
||||
|
|
@ -408,7 +428,8 @@
|
|||
"text-xs 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 && "text-gray-500 dark:text-gray-400"
|
||||
row.device.status == :unknown &&
|
||||
"text-gray-500 dark:text-gray-400"
|
||||
]}>
|
||||
{row.device.status |> to_string() |> String.upcase()}
|
||||
</span>
|
||||
|
|
@ -418,7 +439,10 @@
|
|||
<td class="p-0 text-sm 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")]}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
]}
|
||||
>
|
||||
<% {text, color} = last_seen_text(row.device.last_seen_at) %>
|
||||
<span class={"text-xs font-medium #{color}"}>{text}</span>
|
||||
|
|
@ -427,7 +451,10 @@
|
|||
<td class="p-0 text-sm 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")]}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
]}
|
||||
>
|
||||
<% {rt_text, rt_color} = response_time_badge(row.response) %>
|
||||
<span class={"text-xs font-mono #{rt_color}"}>{rt_text}</span>
|
||||
|
|
@ -436,7 +463,10 @@
|
|||
<td class="p-0 text-sm 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")]}
|
||||
class={[
|
||||
"block px-3 text-inherit no-underline",
|
||||
if(@compact_mode, do: "py-1.5", else: "py-4")
|
||||
]}
|
||||
>
|
||||
<%= 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">
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@
|
|||
>
|
||||
<div class="mb-6 -mt-2">
|
||||
<.breadcrumb items={
|
||||
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Devices", navigate: ~p"/devices"}] ++
|
||||
if(@device.site, do: [%{label: @device.site.name, navigate: ~p"/sites/#{@device.site.id}"}], else: []) ++
|
||||
[%{label: @device.name}]
|
||||
[
|
||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||
%{label: "Devices", navigate: ~p"/devices"}
|
||||
] ++
|
||||
if(@device.site,
|
||||
do: [%{label: @device.site.name, navigate: ~p"/sites/#{@device.site.id}"}],
|
||||
else: []
|
||||
) ++
|
||||
[%{label: @device.name}]
|
||||
} />
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
|
@ -590,9 +596,13 @@
|
|||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
|
||||
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" /> Recent Config Changes
|
||||
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" />
|
||||
Recent Config Changes
|
||||
</h3>
|
||||
<.link navigate={~p"/devices/#{@device.id}/config-timeline"} class="text-xs text-blue-500 hover:underline">
|
||||
<.link
|
||||
navigate={~p"/devices/#{@device.id}/config-timeline"}
|
||||
class="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
View Timeline →
|
||||
</.link>
|
||||
</div>
|
||||
|
|
@ -604,7 +614,12 @@
|
|||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{Calendar.strftime(event.changed_at, "%b %d %H:%M")}
|
||||
</span>
|
||||
<span :for={s <- Enum.take(event.sections_changed, 3)} class="badge badge-xs badge-ghost">{s}</span>
|
||||
<span
|
||||
:for={s <- Enum.take(event.sections_changed, 3)}
|
||||
class="badge badge-xs badge-ghost"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-xs">{event.change_size} lines</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@
|
|||
<nav class="flex space-x-4">
|
||||
<.link
|
||||
id="filter-active"
|
||||
patch={~p"/insights?#{build_filter_params(%{source: @filter_source, urgency: @filter_urgency}, %{status: "active"})}"}
|
||||
patch={
|
||||
~p"/insights?#{build_filter_params(%{source: @filter_source, urgency: @filter_urgency}, %{status: "active"})}"
|
||||
}
|
||||
class={[
|
||||
"rounded-md px-3 py-1.5 text-sm font-medium",
|
||||
if(@filter_status == "active",
|
||||
|
|
@ -32,7 +34,9 @@
|
|||
</.link>
|
||||
<.link
|
||||
id="filter-dismissed"
|
||||
patch={~p"/insights?#{build_filter_params(%{source: @filter_source, urgency: @filter_urgency}, %{status: "dismissed"})}"}
|
||||
patch={
|
||||
~p"/insights?#{build_filter_params(%{source: @filter_source, urgency: @filter_urgency}, %{status: "dismissed"})}"
|
||||
}
|
||||
class={[
|
||||
"rounded-md px-3 py-1.5 text-sm font-medium",
|
||||
if(@filter_status == "dismissed",
|
||||
|
|
@ -51,7 +55,9 @@
|
|||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Source:</span>
|
||||
<.link
|
||||
id="filter-source-all"
|
||||
patch={~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{})}"}
|
||||
patch={
|
||||
~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{})}"
|
||||
}
|
||||
class={[
|
||||
"rounded-md px-2 py-1 text-xs font-medium",
|
||||
if(is_nil(@filter_source),
|
||||
|
|
@ -65,7 +71,9 @@
|
|||
<.link
|
||||
:for={source <- ~w(preseem gaiia snmp system)}
|
||||
id={"filter-source-#{source}"}
|
||||
patch={~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{source: source})}"}
|
||||
patch={
|
||||
~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{source: source})}"
|
||||
}
|
||||
class={[
|
||||
"rounded-md px-2 py-1 text-xs font-medium capitalize",
|
||||
if(@filter_source == source,
|
||||
|
|
@ -83,7 +91,9 @@
|
|||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Urgency:</span>
|
||||
<.link
|
||||
id="filter-urgency-all"
|
||||
patch={~p"/insights?#{build_filter_params(%{status: @filter_status, source: @filter_source}, %{})}"}
|
||||
patch={
|
||||
~p"/insights?#{build_filter_params(%{status: @filter_status, source: @filter_source}, %{})}"
|
||||
}
|
||||
class={[
|
||||
"rounded-md px-2 py-1 text-xs font-medium",
|
||||
if(is_nil(@filter_urgency),
|
||||
|
|
@ -95,9 +105,18 @@
|
|||
All
|
||||
</.link>
|
||||
<.link
|
||||
:for={{urgency, classes} <- [{"critical", "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"}, {"warning", "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"}, {"info", "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"}]}
|
||||
:for={
|
||||
{urgency, classes} <- [
|
||||
{"critical", "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"},
|
||||
{"warning",
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"},
|
||||
{"info", "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"}
|
||||
]
|
||||
}
|
||||
id={"filter-urgency-#{urgency}"}
|
||||
patch={~p"/insights?#{build_filter_params(%{status: @filter_status, source: @filter_source}, %{urgency: urgency})}"}
|
||||
patch={
|
||||
~p"/insights?#{build_filter_params(%{status: @filter_status, source: @filter_source}, %{urgency: urgency})}"
|
||||
}
|
||||
class={[
|
||||
"rounded-md px-2 py-1 text-xs font-medium capitalize",
|
||||
if(@filter_urgency == urgency,
|
||||
|
|
@ -146,7 +165,10 @@
|
|||
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
|
||||
<div class="card-body items-center text-center">
|
||||
<div class="rounded-full bg-amber-100 dark:bg-amber-900/40 p-4 mb-2">
|
||||
<.icon name="hero-light-bulb-solid" class="h-12 w-12 text-amber-500 dark:text-amber-400" />
|
||||
<.icon
|
||||
name="hero-light-bulb-solid"
|
||||
class="h-12 w-12 text-amber-500 dark:text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="card-title text-gray-900 dark:text-white">No Insights Yet</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
>
|
||||
<.header>
|
||||
Gaiia Inventory Reconciliation
|
||||
<:subtitle>Compare Gaiia inventory against Towerops device discovery. Reconciliation runs nightly.</:subtitle>
|
||||
<:subtitle>
|
||||
Compare Gaiia inventory against Towerops device discovery. Reconciliation runs nightly.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%!-- Sub-navigation tabs --%>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,10 @@
|
|||
|
||||
<%= if integration.last_synced_at do %>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
· Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m
|
||||
· Next sync in ~{next_sync_minutes(
|
||||
integration.last_synced_at,
|
||||
integration.sync_interval_minutes
|
||||
)}m
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,10 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
|
||||
defp qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
|
||||
defp qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
|
||||
defp qoe_bg(score) when score >= 6.0, do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
|
||||
|
||||
defp qoe_bg(score) when score >= 6.0,
|
||||
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
|
||||
|
||||
defp qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
|
||||
|
||||
defp capacity_bar_color(nil), do: "bg-gray-300"
|
||||
|
|
@ -163,8 +166,8 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
cond do
|
||||
seconds < 60 -> "#{seconds}s ago"
|
||||
seconds < 3600 -> "#{div(seconds, 60)}m ago"
|
||||
seconds < 86400 -> "#{div(seconds, 3600)}h ago"
|
||||
true -> "#{div(seconds, 86400)}d ago"
|
||||
seconds < 86_400 -> "#{div(seconds, 3600)}h ago"
|
||||
true -> "#{div(seconds, 86_400)}d ago"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<.icon name="hero-users" class="h-4 w-4" />
|
||||
Subscribers
|
||||
<.icon name="hero-users" class="h-4 w-4" /> Subscribers
|
||||
</div>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{format_number(@site_summary.subscribers || 0)}
|
||||
|
|
@ -45,8 +44,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<.icon name="hero-currency-dollar" class="h-4 w-4" />
|
||||
Monthly Revenue
|
||||
<.icon name="hero-currency-dollar" class="h-4 w-4" /> Monthly Revenue
|
||||
</div>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{format_mrr(@site_summary.mrr)}
|
||||
|
|
@ -57,8 +55,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<.icon name="hero-server" class="h-4 w-4" />
|
||||
Devices
|
||||
<.icon name="hero-server" class="h-4 w-4" /> Devices
|
||||
</div>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{format_number(@site_summary.device_count)}
|
||||
|
|
@ -74,8 +71,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<.icon name="hero-bell-alert" class="h-4 w-4" />
|
||||
Active Alerts
|
||||
<.icon name="hero-bell-alert" class="h-4 w-4" /> Active Alerts
|
||||
</div>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{length(@active_alerts)}
|
||||
|
|
@ -89,7 +85,9 @@
|
|||
<div class={["mt-6 rounded-lg border p-4", qoe_bg(@qoe_summary[:score])]}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Preseem QoE Score</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Preseem QoE Score
|
||||
</h3>
|
||||
<p class={["mt-1 text-3xl font-bold", qoe_color(@qoe_summary[:score])]}>
|
||||
{if @qoe_summary[:score], do: Float.round(@qoe_summary[:score] / 1, 1), else: "—"}
|
||||
</p>
|
||||
|
|
@ -104,7 +102,9 @@
|
|||
style={"width: #{min(@qoe_summary[:capacity_score], 100)}%"}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{@qoe_summary[:capacity_score]}%</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{@qoe_summary[:capacity_score]}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -165,8 +165,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-base">
|
||||
<.icon name="hero-bell-alert" class="h-5 w-5" />
|
||||
Active Alerts
|
||||
<.icon name="hero-bell-alert" class="h-5 w-5" /> Active Alerts
|
||||
<%= if @active_alerts != [] do %>
|
||||
<span class="badge badge-error badge-sm">{length(@active_alerts)}</span>
|
||||
<% end %>
|
||||
|
|
@ -202,8 +201,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-base">
|
||||
<.icon name="hero-document-text" class="h-5 w-5" />
|
||||
Recent Config Changes
|
||||
<.icon name="hero-document-text" class="h-5 w-5" /> Recent Config Changes
|
||||
</h3>
|
||||
<%= if @config_changes == [] do %>
|
||||
<div class="mt-2 text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -240,8 +238,7 @@
|
|||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-base">
|
||||
<.icon name="hero-server" class="h-5 w-5" />
|
||||
Device Health
|
||||
<.icon name="hero-server" class="h-5 w-5" /> Device Health
|
||||
</h3>
|
||||
<.button
|
||||
navigate={~p"/devices/new?site_id=#{@site.id}"}
|
||||
|
|
@ -253,7 +250,10 @@
|
|||
</div>
|
||||
<%= if @device == [] do %>
|
||||
<div class="mt-2 rounded-lg border-2 border-dashed border-base-300 p-8 text-center">
|
||||
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<.icon
|
||||
name="hero-server"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<h4 class="mt-4 text-base font-semibold text-gray-900 dark:text-white">
|
||||
Add your first device
|
||||
</h4>
|
||||
|
|
@ -275,7 +275,9 @@
|
|||
>
|
||||
<span class={["h-3 w-3 shrink-0 rounded-full", status_dot_class(eq.status)]} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{eq.name}</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{eq.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{eq.ip_address}</p>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -292,8 +294,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-base">
|
||||
<.icon name="hero-light-bulb" class="h-5 w-5" />
|
||||
Preseem Insights
|
||||
<.icon name="hero-light-bulb" class="h-5 w-5" /> Preseem Insights
|
||||
</h3>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
|
|
@ -326,8 +327,7 @@
|
|||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-base">
|
||||
<.icon name="hero-chart-bar" class="h-5 w-5" />
|
||||
Site Latency — Last 24 Hours
|
||||
<.icon name="hero-chart-bar" class="h-5 w-5" /> Site Latency — Last 24 Hours
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -14,9 +14,20 @@ defmodule ToweropsWeb.TraceLive.Index do
|
|||
|
||||
defp issues_callout(assigns) do
|
||||
issues = []
|
||||
issues = if assigns.trace[:device] && assigns.trace.device[:status] == :down, do: ["Device is DOWN" | issues], else: issues
|
||||
issues = if assigns.trace[:qoe_metrics] && assigns.trace.qoe_metrics[:qoe_score] && assigns.trace.qoe_metrics.qoe_score < 50, do: ["QoE score is critical (#{assigns.trace.qoe_metrics.qoe_score})" | issues], else: issues
|
||||
issues = if assigns.trace[:active_alerts] && length(assigns.trace.active_alerts) > 0, do: ["#{length(assigns.trace.active_alerts)} active alert(s)" | issues], else: issues
|
||||
|
||||
issues =
|
||||
if assigns.trace[:device] && assigns.trace.device[:status] == :down, do: ["Device is DOWN" | issues], else: issues
|
||||
|
||||
issues =
|
||||
if assigns.trace[:qoe_metrics] && assigns.trace.qoe_metrics[:qoe_score] && assigns.trace.qoe_metrics.qoe_score < 50,
|
||||
do: ["QoE score is critical (#{assigns.trace.qoe_metrics.qoe_score})" | issues],
|
||||
else: issues
|
||||
|
||||
issues =
|
||||
if assigns.trace[:active_alerts] && assigns.trace.active_alerts != [],
|
||||
do: ["#{length(assigns.trace.active_alerts)} active alert(s)" | issues],
|
||||
else: issues
|
||||
|
||||
assigns = assign(assigns, :issues, Enum.reverse(issues))
|
||||
|
||||
~H"""
|
||||
|
|
@ -37,12 +48,14 @@ defmodule ToweropsWeb.TraceLive.Index do
|
|||
attr :quality, :atom, default: :unknown
|
||||
|
||||
defp metric_card(assigns) do
|
||||
color = case assigns.quality do
|
||||
:good -> "border-green-200 dark:border-green-800"
|
||||
:warning -> "border-yellow-200 dark:border-yellow-800"
|
||||
:bad -> "border-red-200 dark:border-red-800"
|
||||
_ -> "border-base-200"
|
||||
end
|
||||
color =
|
||||
case assigns.quality do
|
||||
:good -> "border-green-200 dark:border-green-800"
|
||||
:warning -> "border-yellow-200 dark:border-yellow-800"
|
||||
:bad -> "border-red-200 dark:border-red-800"
|
||||
_ -> "border-base-200"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :color, color)
|
||||
|
||||
~H"""
|
||||
|
|
@ -93,10 +106,10 @@ defmodule ToweropsWeb.TraceLive.Index do
|
|||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
results =
|
||||
if String.trim(query) != "" do
|
||||
Trace.search(organization.id, query)
|
||||
else
|
||||
if String.trim(query) == "" do
|
||||
[]
|
||||
else
|
||||
Trace.search(organization.id, query)
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -152,13 +165,15 @@ defmodule ToweropsWeb.TraceLive.Index do
|
|||
defp format_speed(speed), do: "#{speed}"
|
||||
|
||||
defp format_relative_time(nil), do: "—"
|
||||
|
||||
defp format_relative_time(dt) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)}m ago"
|
||||
diff < 86400 -> "#{div(diff, 3600)}h ago"
|
||||
true -> "#{div(diff, 86400)}d ago"
|
||||
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
||||
true -> "#{div(diff, 86_400)}d ago"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<.breadcrumb items={
|
||||
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++
|
||||
if(@trace && @trace.subscriber, do: [%{label: @trace.subscriber.name}], else: [])
|
||||
if(@trace && @trace.subscriber, do: [%{label: @trace.subscriber.name}], else: [])
|
||||
} />
|
||||
<div class="space-y-6">
|
||||
<%!-- Search Bar --%>
|
||||
|
|
@ -29,15 +29,17 @@
|
|||
phx-click="clear"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" />
|
||||
Clear
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" /> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Search Results --%>
|
||||
<div :if={@results != [] && !@trace} class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
|
||||
<div
|
||||
:if={@results != [] && !@trace}
|
||||
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
|
||||
>
|
||||
<div class="card-body p-0">
|
||||
<div class="divide-y divide-base-200 dark:divide-white/10">
|
||||
<button
|
||||
|
|
@ -52,7 +54,9 @@
|
|||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">{result.label}</div>
|
||||
<div :if={result.sublabel} class="text-sm text-gray-500 dark:text-gray-400 truncate">{result.sublabel}</div>
|
||||
<div :if={result.sublabel} class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{result.sublabel}
|
||||
</div>
|
||||
</div>
|
||||
<.icon name="hero-chevron-right" class="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
</button>
|
||||
|
|
@ -61,10 +65,15 @@
|
|||
</div>
|
||||
|
||||
<%!-- No Results --%>
|
||||
<div :if={@query != "" && @results == [] && !@trace} class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
:if={@query != "" && @results == [] && !@trace}
|
||||
class="text-center py-12 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-lg font-medium">No results found</p>
|
||||
<p class="text-sm">Try searching by customer name, IP address, account ID, or device name</p>
|
||||
<p class="text-sm">
|
||||
Try searching by customer name, IP address, account ID, or device name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Trace View --%>
|
||||
|
|
@ -82,36 +91,60 @@
|
|||
</h3>
|
||||
<%= if @trace.subscriber do %>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-white">{@trace.subscriber.name}</div>
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{@trace.subscriber.name}
|
||||
</div>
|
||||
<div :if={@trace.subscriber.plan} class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{String.capitalize(@trace.subscriber.plan)}
|
||||
<span :if={@trace.subscriber.plan_mrr} class="font-medium">
|
||||
— ${if(is_struct(@trace.subscriber.plan_mrr, Decimal), do: Decimal.round(@trace.subscriber.plan_mrr, 2), else: @trace.subscriber.plan_mrr)}/mo
|
||||
— ${if(is_struct(@trace.subscriber.plan_mrr, Decimal),
|
||||
do: Decimal.round(@trace.subscriber.plan_mrr, 2),
|
||||
else: @trace.subscriber.plan_mrr
|
||||
)}/mo
|
||||
</span>
|
||||
</div>
|
||||
<div :if={@trace.subscriber.speed_download} class="text-sm text-gray-500 dark:text-gray-400">
|
||||
↓ {format_speed(@trace.subscriber.speed_download)} / ↑ {format_speed(@trace.subscriber.speed_upload)}
|
||||
<div
|
||||
:if={@trace.subscriber.speed_download}
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
↓ {format_speed(@trace.subscriber.speed_download)} / ↑ {format_speed(
|
||||
@trace.subscriber.speed_upload
|
||||
)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Account #: {@trace.subscriber.account_id}
|
||||
</div>
|
||||
<div :if={@trace.subscriber.address} class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
:if={@trace.subscriber.address}
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<.icon name="hero-map-pin" class="h-3.5 w-3.5 inline" /> {@trace.subscriber.address}
|
||||
</div>
|
||||
<div :if={@trace.subscriber.customer_since} class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
:if={@trace.subscriber.customer_since}
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Customer since: {Calendar.strftime(@trace.subscriber.customer_since, "%b %Y")}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class={["badge badge-sm", status_badge_class(@trace.subscriber.status)]}>
|
||||
{@trace.subscriber.status || "unknown"}
|
||||
</span>
|
||||
<span :if={@trace.subscriber.mrr} class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
MRR: ${if(is_struct(@trace.subscriber.mrr, Decimal), do: Decimal.round(@trace.subscriber.mrr, 2), else: @trace.subscriber.mrr)}
|
||||
<span
|
||||
:if={@trace.subscriber.mrr}
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
MRR: ${if(is_struct(@trace.subscriber.mrr, Decimal),
|
||||
do: Decimal.round(@trace.subscriber.mrr, 2),
|
||||
else: @trace.subscriber.mrr
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No subscriber info available</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No subscriber info available
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -125,32 +158,57 @@
|
|||
<%= if @trace.device do %>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
<.link navigate={~p"/devices/#{@trace.device.id}"} class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
<.link
|
||||
navigate={~p"/devices/#{@trace.device.id}"}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{@trace.device.name || @trace.device.ip_address}
|
||||
</.link>
|
||||
</div>
|
||||
<div :if={@trace.device.site} class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<.link navigate={~p"/sites/#{@trace.device.site.id}"} class="hover:text-blue-600">
|
||||
<.link
|
||||
navigate={~p"/sites/#{@trace.device.site.id}"}
|
||||
class="hover:text-blue-600"
|
||||
>
|
||||
{@trace.device.site.name}
|
||||
</.link>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Status:</span>
|
||||
<span class={["inline-flex items-center gap-1 text-sm font-medium", device_status_color(@trace.device.status)]}>
|
||||
<span class={["inline-block h-2.5 w-2.5 rounded-full", device_status_dot(@trace.device.status)]}></span>
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 text-sm font-medium",
|
||||
device_status_color(@trace.device.status)
|
||||
]}>
|
||||
<span class={[
|
||||
"inline-block h-2.5 w-2.5 rounded-full",
|
||||
device_status_dot(@trace.device.status)
|
||||
]}>
|
||||
</span>
|
||||
{String.capitalize(to_string(@trace.device.status))}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
IP: {@trace.device.ip_address}
|
||||
</div>
|
||||
<div :if={@trace.device.last_checked_at} class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
:if={@trace.device.last_checked_at}
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Last checked: {format_relative_time(@trace.device.last_checked_at)}
|
||||
</div>
|
||||
<div :if={@trace.access_point} class="mt-2 pt-2 border-t border-base-200 dark:border-white/10">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Preseem AP</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">{@trace.access_point.name}</div>
|
||||
<div :if={@trace.access_point.model} class="text-xs text-gray-500">{@trace.access_point.model}</div>
|
||||
<div
|
||||
:if={@trace.access_point}
|
||||
class="mt-2 pt-2 border-t border-base-200 dark:border-white/10"
|
||||
>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">
|
||||
Preseem AP
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{@trace.access_point.name}
|
||||
</div>
|
||||
<div :if={@trace.access_point.model} class="text-xs text-gray-500">
|
||||
{@trace.access_point.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
|
|
@ -171,36 +229,73 @@
|
|||
</h3>
|
||||
<%= if @trace.qoe_metrics do %>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<.metric_card label="QoE Score" value={format_score(@trace.qoe_metrics.qoe_score)} quality={score_quality(@trace.qoe_metrics.qoe_score)} />
|
||||
<.metric_card label="Capacity" value={format_score(@trace.qoe_metrics.capacity_score)} quality={score_quality(@trace.qoe_metrics.capacity_score)} />
|
||||
<.metric_card label="RF Score" value={format_score(@trace.qoe_metrics.rf_score)} quality={score_quality(@trace.qoe_metrics.rf_score)} />
|
||||
<.metric_card label="Airtime" value={format_pct(@trace.qoe_metrics.airtime_utilization)} quality={airtime_quality(@trace.qoe_metrics.airtime_utilization)} />
|
||||
<.metric_card
|
||||
label="QoE Score"
|
||||
value={format_score(@trace.qoe_metrics.qoe_score)}
|
||||
quality={score_quality(@trace.qoe_metrics.qoe_score)}
|
||||
/>
|
||||
<.metric_card
|
||||
label="Capacity"
|
||||
value={format_score(@trace.qoe_metrics.capacity_score)}
|
||||
quality={score_quality(@trace.qoe_metrics.capacity_score)}
|
||||
/>
|
||||
<.metric_card
|
||||
label="RF Score"
|
||||
value={format_score(@trace.qoe_metrics.rf_score)}
|
||||
quality={score_quality(@trace.qoe_metrics.rf_score)}
|
||||
/>
|
||||
<.metric_card
|
||||
label="Airtime"
|
||||
value={format_pct(@trace.qoe_metrics.airtime_utilization)}
|
||||
quality={airtime_quality(@trace.qoe_metrics.airtime_utilization)}
|
||||
/>
|
||||
</div>
|
||||
<%= if @trace.qoe_metrics.latest_metric do %>
|
||||
<div class="mt-4 pt-3 border-t border-base-200 dark:border-white/10">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Latest Metrics</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">
|
||||
Latest Metrics
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Latency</span>
|
||||
<span class={["font-medium", latency_color(@trace.qoe_metrics.latest_metric.avg_latency)]}>{format_ms(@trace.qoe_metrics.latest_metric.avg_latency)}</span>
|
||||
<span class={[
|
||||
"font-medium",
|
||||
latency_color(@trace.qoe_metrics.latest_metric.avg_latency)
|
||||
]}>
|
||||
{format_ms(@trace.qoe_metrics.latest_metric.avg_latency)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Jitter</span>
|
||||
<span class={["font-medium", jitter_color(@trace.qoe_metrics.latest_metric.avg_jitter)]}>{format_ms(@trace.qoe_metrics.latest_metric.avg_jitter)}</span>
|
||||
<span class={[
|
||||
"font-medium",
|
||||
jitter_color(@trace.qoe_metrics.latest_metric.avg_jitter)
|
||||
]}>
|
||||
{format_ms(@trace.qoe_metrics.latest_metric.avg_jitter)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Loss</span>
|
||||
<span class={["font-medium", loss_color(@trace.qoe_metrics.latest_metric.avg_loss)]}>{format_pct(@trace.qoe_metrics.latest_metric.avg_loss)}</span>
|
||||
<span class={[
|
||||
"font-medium",
|
||||
loss_color(@trace.qoe_metrics.latest_metric.avg_loss)
|
||||
]}>
|
||||
{format_pct(@trace.qoe_metrics.latest_metric.avg_loss)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Throughput</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{format_throughput(@trace.qoe_metrics.latest_metric.avg_throughput)}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{format_throughput(@trace.qoe_metrics.latest_metric.avg_throughput)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No Preseem data available</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No Preseem data available
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,34 +309,65 @@
|
|||
<%= if @trace.peer_impact do %>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Subscribers on this AP</span>
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white">{@trace.peer_impact.subscriber_count}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Subscribers on this AP
|
||||
</span>
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{@trace.peer_impact.subscriber_count}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Other APs on device</span>
|
||||
<span class="text-lg font-semibold text-gray-700 dark:text-gray-300">{@trace.peer_impact.peer_ap_count}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Other APs on device
|
||||
</span>
|
||||
<span class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{@trace.peer_impact.peer_ap_count}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Active alerts on device</span>
|
||||
<span class={["text-lg font-semibold", if(@trace.peer_impact.active_alert_count > 0, do: "text-red-600", else: "text-green-600")]}>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Active alerts on device
|
||||
</span>
|
||||
<span class={[
|
||||
"text-lg font-semibold",
|
||||
if(@trace.peer_impact.active_alert_count > 0,
|
||||
do: "text-red-600",
|
||||
else: "text-green-600"
|
||||
)
|
||||
]}>
|
||||
{@trace.peer_impact.active_alert_count}
|
||||
</span>
|
||||
</div>
|
||||
<div :if={@trace.peer_impact.peer_aps != []} class="mt-3 pt-3 border-t border-base-200 dark:border-white/10">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Peer APs</div>
|
||||
<div
|
||||
:if={@trace.peer_impact.peer_aps != []}
|
||||
class="mt-3 pt-3 border-t border-base-200 dark:border-white/10"
|
||||
>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">
|
||||
Peer APs
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div :for={ap <- @trace.peer_impact.peer_aps} class="flex items-center justify-between text-sm">
|
||||
<div
|
||||
:for={ap <- @trace.peer_impact.peer_aps}
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-gray-700 dark:text-gray-300 truncate">{ap.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">{ap.subscriber_count || 0} subs</span>
|
||||
<span :if={ap.qoe_score} class={["font-medium", score_color(ap.qoe_score)]}>{format_score(ap.qoe_score)}</span>
|
||||
<span
|
||||
:if={ap.qoe_score}
|
||||
class={["font-medium", score_color(ap.qoe_score)]}
|
||||
>
|
||||
{format_score(ap.qoe_score)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No peer data available</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No peer data available
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -260,8 +386,14 @@
|
|||
</div>
|
||||
<% else %>
|
||||
<div class="divide-y divide-base-200 dark:divide-white/10">
|
||||
<div :for={alert <- @trace.alerts} class="py-2 first:pt-0 last:pb-0 flex items-center gap-3">
|
||||
<span class={["inline-block h-2.5 w-2.5 rounded-full flex-shrink-0", alert_dot(alert)]}>
|
||||
<div
|
||||
:for={alert <- @trace.alerts}
|
||||
class="py-2 first:pt-0 last:pb-0 flex items-center gap-3"
|
||||
>
|
||||
<span class={[
|
||||
"inline-block h-2.5 w-2.5 rounded-full flex-shrink-0",
|
||||
alert_dot(alert)
|
||||
]}>
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
|
|
@ -275,7 +407,9 @@
|
|||
{format_relative_time(alert.triggered_at)}
|
||||
</div>
|
||||
<span :if={alert.resolved_at} class="badge badge-xs badge-success">resolved</span>
|
||||
<span :if={is_nil(alert.resolved_at)} class="badge badge-xs badge-error">active</span>
|
||||
<span :if={is_nil(alert.resolved_at)} class="badge badge-xs badge-error">
|
||||
active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -302,9 +436,14 @@
|
|||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{Map.get(change, :changed_sections, []) |> Enum.join(", ")}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">{format_relative_time(change.detected_at)}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{format_relative_time(change.detected_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div :if={Map.get(change, :diff_summary)} class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono truncate">
|
||||
<div
|
||||
:if={Map.get(change, :diff_summary)}
|
||||
class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono truncate"
|
||||
>
|
||||
{change.diff_summary}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -327,11 +466,21 @@
|
|||
<% else %>
|
||||
<div class="space-y-2">
|
||||
<div :for={insight <- @trace.insights} class="flex items-start gap-2">
|
||||
<span class={["mt-0.5 inline-block h-2.5 w-2.5 rounded-full flex-shrink-0", urgency_dot(insight.urgency)]}>
|
||||
<span class={[
|
||||
"mt-0.5 inline-block h-2.5 w-2.5 rounded-full flex-shrink-0",
|
||||
urgency_dot(insight.urgency)
|
||||
]}>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{insight.title}</div>
|
||||
<div :if={insight.description} class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">{insight.description}</div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{insight.title}
|
||||
</div>
|
||||
<div
|
||||
:if={insight.description}
|
||||
class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2"
|
||||
>
|
||||
{insight.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -341,7 +490,10 @@
|
|||
</div>
|
||||
|
||||
<%!-- Inventory Items --%>
|
||||
<div :if={@trace.inventory_items != []} class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
|
||||
<div
|
||||
:if={@trace.inventory_items != []}
|
||||
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
|
||||
>
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
|
||||
<.icon name="hero-cube" class="h-4 w-4 inline" /> Equipment (from Gaiia)
|
||||
|
|
@ -363,7 +515,11 @@
|
|||
<td class="font-mono text-sm">{item.ip_address}</td>
|
||||
<td>{item.model_name}</td>
|
||||
<td class="font-mono text-xs">{item.serial_number}</td>
|
||||
<td><span class={["badge badge-xs", status_badge_class(item.status)]}>{item.status}</span></td>
|
||||
<td>
|
||||
<span class={["badge badge-xs", status_badge_class(item.status)]}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -374,7 +530,10 @@
|
|||
|
||||
<%!-- Empty State --%>
|
||||
<div :if={@query == "" && !@trace} class="text-center py-16">
|
||||
<.icon name="hero-magnifying-glass-circle" class="h-16 w-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<.icon
|
||||
name="hero-magnifying-glass-circle"
|
||||
class="h-16 w-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Subscriber Trace</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
Search for a customer by name, IP address, account ID, or device name to see their full network status at a glance.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,16 @@ defmodule Towerops.Repo.Migrations.CreateConfigChangeEvents do
|
|||
create table(:config_change_events, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :backup_before_id, references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all)
|
||||
add :backup_after_id, references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :backup_before_id,
|
||||
references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
add :backup_after_id,
|
||||
references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
add :changed_at, :utc_datetime, null: false
|
||||
add :diff_summary, :text
|
||||
add :sections_changed, {:array, :string}, default: []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue