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:
Graham McIntire 2026-02-13 19:34:40 -06:00
parent 493b1e8001
commit 9e7ee5099d
33 changed files with 743 additions and 339 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: []