towerops/lib/towerops_web/live/site_live/show.html.heex

586 lines
25 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="sites"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "Sites", navigate: ~p"/sites"},
%{label: @site.name}
]} />
<!-- Prominent site header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@site.name}</h1>
<p :if={@site.location} class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-map-pin" class="h-4 w-4 inline" /> {@site.location}
<%= if @site.latitude && @site.longitude do %>
<span class="ml-2 font-mono text-xs text-gray-400">
({Float.round(@site.latitude, 4)}, {Float.round(@site.longitude, 4)})
</span>
<% end %>
</p>
</div>
<!-- Leaflet map if coordinates exist -->
<%= if @site.latitude && @site.longitude do %>
<div
id="site-map"
phx-hook="LeafletMap"
phx-update="ignore"
data-lat={@site.latitude}
data-lng={@site.longitude}
data-zoom="14"
data-marker-title={@site.name}
class="h-48 w-full rounded-lg border border-gray-200 dark:border-white/10 mb-6 z-0"
>
</div>
<% end %>
<%= if @device != [] do %>
<div class="mt-2">
<.button
type="button"
phx-click="force_rediscover_all"
class="btn text-sm !py-1 !px-3"
data-confirm={
t("This will trigger SNMP discovery for all devices at this site. Continue?")
}
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> {t("Force Rediscover All")}
</.button>
</div>
<% end %>
<%!-- Quick Actions --%>
<div class="mt-4 flex flex-wrap gap-2">
<.button
navigate={~p"/devices/new?site_id=#{@site.id}"}
variant="primary"
class="btn text-sm !py-1 !px-3"
>
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Device")}
</.button>
<%= if @site.latitude && @site.longitude do %>
<.button
navigate={~p"/sites-map?lat=#{@site.latitude}&lng=#{@site.longitude}&zoom=15"}
class="btn text-sm !py-1 !px-3"
>
<.icon name="hero-map" class="h-4 w-4" /> {t("View on Map")}
</.button>
<% end %>
<.button navigate={~p"/sites/#{@site.id}/edit"} class="btn text-sm !py-1 !px-3">
<.icon name="hero-pencil" class="h-4 w-4" /> {t("Edit Site")}
</.button>
</div>
<%!-- Summary Stat Cards --%>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<%!-- Subscribers --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<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" /> {t("Subscribers")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white font-mono">
{format_number(@site_summary.subscribers || 0)}
</p>
</div>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials do %>
<%!-- MRR --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<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" /> {t("Monthly Revenue")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_mrr(@site_summary.mrr)}
</p>
</div>
<% end %>
<%!-- Device Count --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<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" /> {t("Devices")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white font-mono">
{format_number(@site_summary.device_count)}
<%= if @site_summary.devices_down > 0 do %>
<span class="text-sm font-medium text-red-600 dark:text-red-400">
({@site_summary.devices_down} {t("down")})
</span>
<% end %>
</p>
</div>
<%!-- Active Alerts --%>
<div class={[
"rounded-lg border bg-white dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm",
if(length(@active_alerts) > 0,
do: "border-red-200 dark:border-red-900/50",
else: "border-gray-200 dark:border-white/10"
)
]}>
<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" /> {t("Active Alerts")}
</div>
<p class={[
"mt-1 text-2xl font-bold font-mono",
if(length(@active_alerts) > 0,
do: "text-red-600 dark:text-red-400",
else: "text-gray-900 dark:text-white"
)
]}>
{length(@active_alerts)}
</p>
</div>
</div>
<%!-- Backhaul Capacity Card --%>
<%= if @capacity_summary.interfaces != [] do %>
<div class="mt-6 rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-signal" class="h-4 w-4 inline mr-1" />{t("Backhaul Capacity")}
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{format_capacity(@capacity_summary.total_capacity_bps)} {t("total")}
</span>
</div>
<div class="flex items-center gap-3 mb-3">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
class={[
"h-full rounded-full transition-all",
utilization_bar_color(@capacity_summary.utilization_pct)
]}
style={"width: #{min(@capacity_summary.utilization_pct, 100)}%"}
>
</div>
</div>
<span class={[
"text-sm font-bold font-mono whitespace-nowrap",
utilization_text_color(@capacity_summary.utilization_pct)
]}>
{Float.round(@capacity_summary.utilization_pct, 1)}%
</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="text-gray-500 dark:text-gray-400">{t("Throughput")}</span>
<p class="font-mono font-medium text-gray-900 dark:text-white">
{format_capacity(@capacity_summary.total_throughput_bps)}
</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{t("Headroom")}</span>
<p class="font-mono font-medium text-gray-900 dark:text-white">
{format_capacity(
max(
0,
@capacity_summary.total_capacity_bps - @capacity_summary.total_throughput_bps
)
)}
</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{t("Interfaces")}</span>
<p class="font-mono font-medium text-gray-900 dark:text-white">
{length(@capacity_summary.interfaces)}
</p>
</div>
</div>
</div>
<% end %>
<%!-- QoE Summary Card --%>
<%= if @qoe_summary do %>
<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">
{t("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>
</div>
<%= if @qoe_summary[:capacity_score] do %>
<div class="text-right">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Capacity</p>
<div class="mt-1 w-32">
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class={["h-2 rounded-full", capacity_bar_color(@qoe_summary[:capacity_score])]}
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>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Subscriber Impact Breakdown by Device --%>
<%= if @site_impact && @site_impact.subscriber_count > 0 do %>
<div class="mt-6 rounded-lg border border-indigo-200 bg-indigo-50 dark:border-indigo-800/50 dark:bg-indigo-950/30">
<div class="px-4 py-3 border-b border-indigo-200 dark:border-indigo-800/50">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-indigo-900 dark:text-indigo-200 flex items-center gap-1.5">
<.icon name="hero-users" class="h-4 w-4" /> {t("Subscriber Impact")}
</h3>
<div class="flex items-center gap-4 text-sm font-medium text-indigo-900 dark:text-indigo-200">
<span>{format_number(@site_impact.subscriber_count)} subscribers</span>
<%= if @can_view_financials && @site_impact.mrr do %>
<span>{format_mrr(@site_impact.mrr)}/mo</span>
<% end %>
</div>
</div>
</div>
<%= if @site_impact.devices != [] do %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-indigo-200 dark:divide-indigo-800/50">
<thead>
<tr class="text-xs text-indigo-700 dark:text-indigo-300">
<th class="px-4 py-2 text-left font-medium">{t("Device")}</th>
<th class="px-4 py-2 text-right font-medium">{t("Subscribers")}</th>
<%= if @can_view_financials do %>
<th class="px-4 py-2 text-right font-medium">{t("MRR")}</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-indigo-100 dark:divide-indigo-900/30">
<%= for dev <- @site_impact.devices do %>
<tr class="text-sm">
<td class="px-4 py-2 text-indigo-900 dark:text-indigo-200 font-medium">
{dev.name || dev.device_id}
</td>
<td class="px-4 py-2 text-right text-indigo-800 dark:text-indigo-300 font-mono">
{dev.count}
</td>
<%= if @can_view_financials do %>
<td class="px-4 py-2 text-right text-indigo-800 dark:text-indigo-300 font-mono">
{format_mrr(dev.mrr)}
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% end %>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<%!-- Left Column --%>
<div class="space-y-6 min-w-0">
<%!-- Site Details --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
{t("Site Details")}
</h3>
<dl class="mt-2 space-y-3">
<%= if @site.parent_site do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Parent Site</dt>
<dd class="mt-0.5">
<.link
navigate={~p"/sites/#{@site.parent_site.id}"}
class="text-sm text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
>
{@site.parent_site.name}
</.link>
</dd>
</div>
<% end %>
<%= if @site.location do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Location</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">{@site.location}</dd>
</div>
<% end %>
<%= if @site.description do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Description</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">{@site.description}</dd>
</div>
<% end %>
</dl>
<%= if @site.child_sites != [] do %>
<div class="mt-4 border-t border-gray-300 dark:border-gray-600 pt-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Child Sites")}
</h4>
<ul class="mt-2 space-y-1">
<li :for={child <- @site.child_sites}>
<.link
navigate={~p"/sites/#{child.id}"}
class="text-sm text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
>
{child.name}
</.link>
</li>
</ul>
</div>
<% end %>
</div>
</div>
<%!-- Active Alerts Panel --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-bell-alert" class="h-5 w-5" /> Active Alerts
<%= if @active_alerts != [] do %>
<span class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
{length(@active_alerts)}
</span>
<% end %>
</h3>
<%= if @active_alerts == [] do %>
<div class="mt-2 text-center py-6 text-gray-500 dark:text-gray-400">
<.icon name="hero-check-circle" class="mx-auto h-8 w-8 text-green-500" />
<p class="mt-2 text-sm">No active alerts</p>
</div>
<% else %>
<div class="mt-2 space-y-2">
<div
:for={alert <- @active_alerts}
class="flex items-start gap-3 rounded-lg border border-gray-200 dark:border-white/10 p-3"
>
<.icon
name={insight_urgency_icon(alert_severity(alert.alert_type))}
class={[
"h-5 w-5 mt-0.5 shrink-0",
insight_urgency_class(alert_severity(alert.alert_type))
]}
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{alert.message}</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{time_ago(alert.triggered_at)}
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Recent Config Changes Panel — hidden, feature temporarily disabled --%>
<div class="hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.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">
<p class="text-sm">No config changes in the last 30 days</p>
</div>
<% else %>
<div class="mt-2 space-y-2">
<div
:for={change <- @config_changes}
class="flex items-center justify-between rounded-lg border border-gray-200 dark:border-white/10 p-3"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{change.device_name || "Unknown device"}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{time_ago(change.detected_at)}
</p>
</div>
<span class={[
"badge bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
change_size_color(change.diff_size || 0)
]}>
{change_size_label(change.diff_size || 0)}
</span>
</div>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Right Column --%>
<div class="space-y-6 min-w-0">
<%!-- Device Health Grid --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<div class="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-server" class="h-5 w-5" /> Device Health
</h3>
<.button
navigate={~p"/devices/new?site_id=#{@site.id}"}
variant="primary"
class="btn text-sm !py-1 !px-3"
>
<.icon name="hero-plus" class="h-4 w-4" /> Add
</.button>
</div>
<%= if @device == [] do %>
<div class="mt-2 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 p-8 text-center">
<.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">
{t("Add your first device")}
</h4>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t("Start monitoring by adding network devices to this site.")}
</p>
<div class="mt-4">
<.button navigate={~p"/devices/new?site_id=#{@site.id}"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Add Device
</.button>
</div>
</div>
<% else %>
<div class="mt-2 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 text-left font-medium">{t("Device")}</th>
<th class="hidden sm:table-cell px-3 py-2 text-left font-medium">
{t("IP Address")}
</th>
<th class="hidden sm:table-cell px-3 py-2 text-right font-medium">
{t("Subs")}
</th>
<%= if @can_view_financials do %>
<th class="hidden md:table-cell px-3 py-2 text-right font-medium">
{t("MRR")}
</th>
<% end %>
<th class="px-3 py-2 text-right font-medium">{t("Latency")}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5">
<tr
:for={eq <- @device}
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/devices/#{eq.id}")}
>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<span class={[
"h-2 w-2 shrink-0 rounded-full",
status_dot_class(eq.status)
]} />
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
{eq.name}
</span>
</div>
</td>
<td class="hidden sm:table-cell px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
{eq.ip_address}
</td>
<td class="hidden sm:table-cell px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
<%= if di = @device_impact[eq.id] do %>
{if di.count > 0, do: di.count, else: "—"}
<% else %>
<% end %>
</td>
<%= if @can_view_financials do %>
<td class="hidden md:table-cell px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
<%= if di = @device_impact[eq.id] do %>
{if Decimal.gt?(di.mrr, 0), do: format_mrr(di.mrr), else: "—"}
<% else %>
<% end %>
</td>
<% end %>
<td class={[
"px-3 py-2 text-right font-mono text-xs",
case @response_times[eq.id] do
nil -> "text-gray-400"
ms when ms < 50 -> "text-green-600 dark:text-green-400"
ms when ms < 200 -> "text-yellow-600 dark:text-yellow-400"
_ -> "text-red-600 dark:text-red-400"
end
]}>
{format_response_time(@response_times[eq.id])}
</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
<%!-- Insights Panel --%>
<%= if @insights != [] do %>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-light-bulb" class="h-5 w-5" /> Preseem Insights
</h3>
<div class="mt-2 space-y-2">
<div
:for={insight <- @insights}
class="flex items-start gap-3 rounded-lg border border-gray-200 dark:border-white/10 p-3"
>
<.icon
name={insight_urgency_icon(insight.urgency)}
class={["h-5 w-5 mt-0.5 shrink-0", insight_urgency_class(insight.urgency)]}
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{insight.title}</p>
<%= if insight.description do %>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{insight.description}
</p>
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Latency Chart --%>
<%= if @latency_chart_data do %>
<div class="mt-6">
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-chart-bar" class="h-4 w-4" /> {t("Site Latency — Last 24 Hours")}
</h3>
</div>
<div class="p-4">
<div
id="site-latency-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@latency_chart_data}
data-unit="ms"
data-auto-scale="true"
class="relative"
style="height: 400px;"
>
<div class="absolute inset-0 flex items-center justify-center" data-loading>
<div class="flex flex-col items-center gap-2">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 dark:border-gray-700 border-t-blue-500">
</div>
<span class="text-xs text-gray-400">{t("Loading chart...")}</span>
</div>
</div>
<canvas></canvas>
</div>
</div>
</div>
</div>
<% end %>
</Layouts.authenticated>