710 lines
31 KiB
Text
710 lines
31 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="trace"
|
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
|
>
|
|
<.breadcrumb items={
|
|
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++
|
|
cond do
|
|
@trace && @trace[:subscriber] -> [%{label: @trace.subscriber.name}]
|
|
@trace && @trace[:site] -> [%{label: @trace.site.name}]
|
|
true -> []
|
|
end
|
|
} />
|
|
<div class="mb-1">
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Trace")}</h1>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Investigate subscriber impact, site health, and network path issues.")}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<%!-- Search Bar --%>
|
|
<div class="card shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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, site name, or device name..."
|
|
)
|
|
}
|
|
class="w-full rounded-field border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 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 text-sm !py-1 !px-3 bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-5 w-5" /> Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Search Results --%>
|
|
<div
|
|
:if={@results != [] && !@trace}
|
|
class="card shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900"
|
|
>
|
|
<div class="p-0">
|
|
<div class="divide-y divide-gray-200 dark:divide-gray-700 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-gray-100/50 dark:hover:bg-gray-800/50 dark:hover:bg-white/5 text-left transition-colors"
|
|
>
|
|
<span class={[
|
|
"badge text-xs !px-2 !py-0 font-mono bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
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, site name, or device name")}
|
|
</p>
|
|
</div>
|
|
|
|
<%!-- Site Trace View --%>
|
|
<div :if={@trace && @trace_type == :site} class="space-y-4">
|
|
<%!-- Site Info --%>
|
|
<div class="card shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="p-4 sm:p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<.icon name="hero-building-office-2" class="h-5 w-5 text-gray-400" />
|
|
{@trace.site.name}
|
|
</h3>
|
|
<div class="mt-2 space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
|
<p :if={@trace.site.location}>
|
|
<span class="font-medium text-gray-500 dark:text-gray-400">Location:</span>
|
|
{@trace.site.location}
|
|
</p>
|
|
<p :if={@trace.site.address}>
|
|
<span class="font-medium text-gray-500 dark:text-gray-400">Address:</span>
|
|
{@trace.site.address}
|
|
</p>
|
|
<p>
|
|
<span class="font-medium text-gray-500 dark:text-gray-400">Devices:</span>
|
|
{length(@trace.devices)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<%!-- Devices at Site --%>
|
|
<div
|
|
:if={length(@trace.devices) > 0}
|
|
class="card shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900"
|
|
>
|
|
<div class="p-4 sm:p-6">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
|
{t("Devices")}
|
|
</h3>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full text-sm">
|
|
<thead>
|
|
<tr class="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
|
<th>{t("Name")}</th>
|
|
<th>{t("IP Address")}</th>
|
|
<th>{t("Status")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
:for={device <- @trace.devices}
|
|
class="hover:bg-gray-100/50 dark:hover:bg-gray-800/50 dark:hover:bg-white/5"
|
|
>
|
|
<td>
|
|
<.link
|
|
navigate={~p"/devices/#{device.id}"}
|
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
{device.name || device.ip_address}
|
|
</.link>
|
|
</td>
|
|
<td class="font-mono text-sm">{device.ip_address}</td>
|
|
<td>
|
|
<span class="flex items-center gap-1.5">
|
|
<span class={"h-2 w-2 rounded-full #{device_status_dot(device.status)}"}>
|
|
</span>
|
|
<span class={device_status_color(device.status)}>
|
|
{device.status || "unknown"}
|
|
</span>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<%!-- Recent Alerts --%>
|
|
<div
|
|
:if={@trace.alerts != []}
|
|
class="card shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900"
|
|
>
|
|
<div class="p-4 sm:p-6">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
|
{t("Recent Alerts")}
|
|
</h3>
|
|
<div class="space-y-2">
|
|
<div
|
|
:for={alert <- @trace.alerts}
|
|
class="flex items-start gap-3 p-2 rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-800/50 dark:hover:bg-white/5"
|
|
>
|
|
<span class={"mt-1.5 h-2 w-2 rounded-full flex-shrink-0 #{alert_dot(alert)}"}>
|
|
</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{format_alert_type(alert.alert_type)}
|
|
</span>
|
|
<span :if={alert.device} class="text-xs text-gray-500 dark:text-gray-400">
|
|
{alert.device.name}
|
|
</span>
|
|
<span
|
|
:if={alert.resolved_at}
|
|
class="badge text-xs !px-2 !py-0 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
>
|
|
{t("Resolved")}
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
|
{alert.message}
|
|
</p>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
{format_relative_time(alert.triggered_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Trace View --%>
|
|
<div :if={@trace && @trace_type != :site} 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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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)}
|
|
<%!-- Financial data: role-gated --%>
|
|
<%= if @can_view_financials do %>
|
|
<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>
|
|
<% end %>
|
|
</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 bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
status_badge_class(@trace.subscriber.status)
|
|
]}>
|
|
{@trace.subscriber.status || "unknown"}
|
|
</span>
|
|
<%!-- Financial data: role-gated --%>
|
|
<%= if @can_view_financials do %>
|
|
<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>
|
|
<% end %>
|
|
</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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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-gray-200 dark:border-gray-700 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 dark:text-gray-400"
|
|
>
|
|
{@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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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-gray-200 dark:border-gray-700 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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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-gray-200 dark:border-gray-700 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 dark:text-gray-400">
|
|
{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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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-gray-200 dark:divide-gray-700 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 text-xs !px-2 !py-0 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
>
|
|
resolved
|
|
</span>
|
|
<span
|
|
:if={is_nil(alert.resolved_at)}
|
|
class="badge text-xs !px-2 !py-0 bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
>
|
|
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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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 dark:text-gray-400">
|
|
{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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900">
|
|
<div class="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 shadow-sm border border-gray-200 dark:border-gray-700 dark:border-white/10 dark:bg-gray-900"
|
|
>
|
|
<div class="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="min-w-full text-sm">
|
|
<thead>
|
|
<tr class="text-xs uppercase text-gray-500 dark:text-gray-400">
|
|
<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:bg-gray-100/50 dark:hover:bg-gray-800/50"
|
|
>
|
|
<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 text-xs !px-2 !py-0 bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
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, site name, or device name to see their full network status at a glance."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Layouts.authenticated>
|