towerops/lib/towerops_web/live/trace_live/index.html.heex
Graham McIntie d32ba58b75 Hide config backup and config change features from UI
Temporarily hide config change tracking, config timeline links,
and config backup references across dashboard, site show, trace,
and home page. Code preserved with hidden class / HTML comments.
2026-02-15 17:26:10 -06:00

551 lines
24 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="trace"
>
<.breadcrumb items={
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++
if(@trace && @trace.subscriber, do: [%{label: @trace.subscriber.name}], else: [])
} />
<div class="space-y-6">
<%!-- Search Bar --%>
<div 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">
<div class="flex items-center gap-3">
<.icon name="hero-magnifying-glass" class="h-6 w-6 text-gray-400 flex-shrink-0" />
<form phx-change="search" phx-submit="search" class="flex-1 flex gap-2">
<input
type="text"
name="query"
value={@query}
placeholder={
t(
"Search by customer name, IP address, account ID, serial number, or device name..."
)
}
class="input input-bordered w-full dark:bg-gray-800 dark:border-white/10 dark:text-white"
phx-debounce="300"
autofocus
/>
</form>
<button
:if={@query != "" || @trace}
phx-click="clear"
class="btn btn-ghost btn-sm"
>
<.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 class="card-body p-0">
<div class="divide-y divide-base-200 dark:divide-white/10">
<button
:for={result <- @results}
phx-click="select_result"
phx-value-type={result.type}
phx-value-id={result.id}
class="w-full flex items-center gap-3 px-4 sm:px-6 py-3 hover:bg-base-200/50 dark:hover:bg-white/5 text-left transition-colors"
>
<span class={["badge badge-sm font-mono", type_badge_class(result.type)]}>
{type_badge_label(result.type)}
</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>
<.icon name="hero-chevron-right" class="h-4 w-4 text-gray-400 flex-shrink-0" />
</button>
</div>
</div>
</div>
<%!-- No Results --%>
<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">
{t("Try searching by customer name, IP address, account ID, or device name")}
</p>
</div>
<%!-- Trace View --%>
<div :if={@trace} class="space-y-4">
<%!-- Known Issues Callout --%>
<.issues_callout trace={@trace} />
<%!-- Subscriber + Connection --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- Subscriber Info --%>
<div 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-user" class="h-4 w-4 inline" /> Subscriber
</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 :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
</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>
<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"
>
<.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"
>
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>
</div>
</div>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
{t("No subscriber info available")}
</p>
<% end %>
</div>
</div>
<%!-- Connection / Device Info --%>
<div 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-signal" class="h-4 w-4 inline" /> Connection
</h3>
<%= 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"
>
{@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"
>
{@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>
{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"
>
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">
{t("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 %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No device linked</p>
<% end %>
</div>
</div>
</div>
<%!-- QoE Metrics + Peer Impact --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- QoE Metrics --%>
<div 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-chart-bar" class="h-4 w-4 inline" /> QoE Metrics
<span class="text-xs font-normal normal-case">(from Preseem)</span>
</h3>
<%= if @trace.qoe_metrics do %>
<div class="grid grid-cols-2 gap-3">
<.metric_card
label={t("QoE Score")}
value={format_score(@trace.qoe_metrics.qoe_score)}
quality={score_quality(@trace.qoe_metrics.qoe_score)}
/>
<.metric_card
label={t("Capacity")}
value={format_score(@trace.qoe_metrics.capacity_score)}
quality={score_quality(@trace.qoe_metrics.capacity_score)}
/>
<.metric_card
label={t("RF Score")}
value={format_score(@trace.qoe_metrics.rf_score)}
quality={score_quality(@trace.qoe_metrics.rf_score)}
/>
<.metric_card
label={t("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">
{t("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>
</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>
</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>
</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>
</div>
</div>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
{t("No Preseem data available")}
</p>
<% end %>
</div>
</div>
<%!-- Peer Impact --%>
<div 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-users" class="h-4 w-4 inline" /> Peer Impact
</h3>
<%= 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">
{t("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">
{t("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">
{t("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">
{t("Peer APs")}
</div>
<div class="space-y-1">
<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>
</div>
</div>
</div>
</div>
</div>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
{t("No peer data available")}
</p>
<% end %>
</div>
</div>
</div>
<%!-- Active Alerts --%>
<div 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-bell-alert" class="h-4 w-4 inline" /> Recent Alerts
</h3>
<%= if @trace.alerts == [] do %>
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-sm font-medium">No alerts — all clear</span>
</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)
]}>
</span>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{format_alert_type(alert.alert_type)}
</span>
<span :if={alert.message} class="text-sm text-gray-500 dark:text-gray-400 ml-1">
— {alert.message}
</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
{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>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Recent Activity: Config Changes + Insights --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- Config Changes — hidden, feature temporarily disabled --%>
<div class="hidden 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-document-text" class="h-4 w-4 inline" /> Config Changes (24h)
</h3>
<%= if @trace.config_changes == [] do %>
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-sm font-medium">No config changes in last 24h</span>
</div>
<% else %>
<div class="space-y-2">
<div :for={change <- @trace.config_changes} class="text-sm">
<div class="flex items-center justify-between">
<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.changed_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"
>
{change.diff_summary}
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Active Insights --%>
<div 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-light-bulb" class="h-4 w-4 inline" /> Active Insights
</h3>
<%= if @trace.insights == [] do %>
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-sm font-medium">No active insights</span>
</div>
<% 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>
<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>
</div>
</div>
<% end %>
</div>
</div>
</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 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)
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase text-gray-500">
<th>Name</th>
<th>IP</th>
<th>Model</th>
<th>Serial</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr :for={item <- @trace.inventory_items} class="hover">
<td class="font-medium text-gray-900 dark:text-white">{item.name}</td>
<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>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<%!-- 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"
/>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{t("Subscriber Trace")}
</h2>
<p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
{t(
"Search for a customer by name, IP address, account ID, or device name to see their full network status at a glance."
)}
</p>
</div>
</div>
</Layouts.authenticated>