towerops/lib/towerops_web/live/trace_live/index.html.heex

380 lines
21 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="trace"
>
<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="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">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">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">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="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="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">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">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>
</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")]}>
{@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 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">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 --%>
<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-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.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">
{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">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.
</p>
</div>
</div>
</Layouts.authenticated>