- Use 2-column grid on mobile, flex-wrap on sm+ - Hide pipe separators on mobile (they break when items wrap) - Stats now flow cleanly in a grid on small screens
852 lines
39 KiB
Text
852 lines
39 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="dashboard"
|
|
>
|
|
<%!-- ═══════════════════════════════════════════════
|
|
NOC HEADER — dense, zero chrome
|
|
═══════════════════════════════════════════════ --%>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<h1 class="text-lg font-bold text-gray-900 dark:text-white">
|
|
{@current_scope.organization.name}
|
|
</h1>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">NOC</span>
|
|
</div>
|
|
<%= if assigns[:last_updated] do %>
|
|
<div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500 font-mono">
|
|
<span title="Last refresh">
|
|
{ToweropsWeb.TimeHelpers.format_time_ago(@last_updated)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
phx-click="refresh_dashboard"
|
|
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition"
|
|
title="Refresh (R)"
|
|
>
|
|
<.icon name="hero-arrow-path" class="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%= if !assigns[:summary] do %>
|
|
<.skeleton_dashboard />
|
|
<% end %>
|
|
|
|
<%!-- Empty State --%>
|
|
<%= if assigns[:summary] && @device_count == 0 do %>
|
|
<div class="mt-12 rounded-xl border-2 border-dashed border-gray-300 bg-gray-50/50 p-16 text-center dark:border-white/20 dark:bg-gray-800/30">
|
|
<.icon name="hero-light-bulb" class="mx-auto h-14 w-14 text-blue-500 dark:text-blue-400" />
|
|
<h3 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{t("Let's get started!")}
|
|
</h3>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
|
{t("Set up your network monitoring in a few steps.")}
|
|
</p>
|
|
<div class="mt-10 max-w-lg mx-auto">
|
|
<div class="space-y-5 text-left">
|
|
<%= if @current_scope.organization.use_sites do %>
|
|
<div class="flex gap-4 items-start">
|
|
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 font-semibold text-sm dark:bg-blue-900 dark:text-blue-300">
|
|
1
|
|
</div>
|
|
<div>
|
|
<h4 class="font-medium text-gray-900 dark:text-white">{t("Create a Site")}</h4>
|
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Sites represent your physical locations")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-4 items-start">
|
|
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 text-gray-500 font-semibold text-sm dark:bg-gray-700 dark:text-gray-400">
|
|
2
|
|
</div>
|
|
<div>
|
|
<h4 class="font-medium text-gray-900 dark:text-white">{t("Add Devices")}</h4>
|
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Add routers, switches, and servers to your sites")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="flex gap-4 items-start">
|
|
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 font-semibold text-sm dark:bg-blue-900 dark:text-blue-300">
|
|
1
|
|
</div>
|
|
<div>
|
|
<h4 class="font-medium text-gray-900 dark:text-white">{t("Add Devices")}</h4>
|
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Add network devices to monitor")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<div class="flex gap-4 items-start">
|
|
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 text-gray-500 font-semibold text-sm dark:bg-gray-700 dark:text-gray-400">
|
|
{if @current_scope.organization.use_sites, do: "3", else: "2"}
|
|
</div>
|
|
<div>
|
|
<h4 class="font-medium text-gray-900 dark:text-white">
|
|
{t("Monitor & Receive Alerts")}
|
|
</h4>
|
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("TowerOps automatically monitors your devices and alerts you of issues")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-10">
|
|
<%= if @current_scope.organization.use_sites do %>
|
|
<.button navigate={~p"/sites/new"} variant="primary">
|
|
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
|
|
</.button>
|
|
<% else %>
|
|
<.button navigate={~p"/devices/new"} variant="primary">
|
|
<.icon name="hero-plus" class="h-5 w-5" /> Add Your First Device
|
|
</.button>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if assigns[:summary] && @device_count > 0 do %>
|
|
<%!-- ═══════════════════════════════════════════════
|
|
CONDENSED STATUS BAR — single dense line
|
|
═══════════════════════════════════════════════ --%>
|
|
<div class="mt-4 grid grid-cols-2 gap-0.5 rounded-md border border-gray-200 bg-gray-50 px-1 py-1 dark:border-white/10 dark:bg-gray-800/80 font-mono text-xs sm:flex sm:flex-wrap sm:items-center sm:gap-1">
|
|
<%!-- Health --%>
|
|
<div class={[
|
|
"flex items-center gap-1.5 rounded px-2.5 py-1.5",
|
|
cond do
|
|
@summary.health_score > 80 -> "bg-green-100/80 dark:bg-green-900/30"
|
|
@summary.health_score > 50 -> "bg-yellow-100/80 dark:bg-yellow-900/30"
|
|
true -> "bg-red-100/80 dark:bg-red-900/30"
|
|
end
|
|
]}>
|
|
<span class="text-gray-500 dark:text-gray-400 text-[10px] uppercase font-sans font-medium">
|
|
Health
|
|
</span>
|
|
<span class={[
|
|
"font-bold text-sm",
|
|
health_score_color(@summary.health_score)
|
|
]}>
|
|
{@summary.health_score}
|
|
</span>
|
|
<span class={[
|
|
"text-[10px]",
|
|
health_score_color(@summary.health_score)
|
|
]}>
|
|
{cond do
|
|
@summary.health_score > 80 -> "▲"
|
|
@summary.health_score > 50 -> "▬"
|
|
true -> "▼"
|
|
end}
|
|
</span>
|
|
</div>
|
|
|
|
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
|
|
|
|
<%!-- Uptime --%>
|
|
<div class="flex items-center gap-1.5 px-2.5 py-1.5">
|
|
<span class="text-gray-500 dark:text-gray-400 text-[10px] uppercase font-sans font-medium">
|
|
Up
|
|
</span>
|
|
<span class={["font-bold text-sm", uptime_color(@uptime_percentage)]}>
|
|
{if @uptime_percentage == 100.0,
|
|
do: "100",
|
|
else: :erlang.float_to_binary(@uptime_percentage, decimals: 1)}%
|
|
</span>
|
|
<span class="text-gray-400 dark:text-gray-500">
|
|
{@device_up}/{@device_count}
|
|
</span>
|
|
<%= if @device_down > 0 do %>
|
|
<span class="text-red-500 font-bold">{@device_down}↓</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
|
|
|
|
<%!-- Alerts --%>
|
|
<.link
|
|
navigate={~p"/alerts"}
|
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded hover:bg-gray-200/50 dark:hover:bg-white/5 transition"
|
|
>
|
|
<span class="text-gray-500 dark:text-gray-400 text-[10px] uppercase font-sans font-medium">
|
|
Alerts
|
|
</span>
|
|
<%= if @total_alert_count > 0 do %>
|
|
<span class="font-bold text-sm text-red-600 dark:text-red-400">
|
|
{@total_alert_count}
|
|
</span>
|
|
<span class="text-red-500">
|
|
{String.duplicate("●", min(@total_alert_count, 5))}
|
|
</span>
|
|
<% else %>
|
|
<span class="font-bold text-sm text-green-600 dark:text-green-400">0</span>
|
|
<span class="text-green-500">✓</span>
|
|
<% end %>
|
|
</.link>
|
|
|
|
<%= if @current_scope.organization.use_sites && @sites_count > 0 do %>
|
|
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
|
|
<.link
|
|
navigate={~p"/sites"}
|
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded hover:bg-gray-200/50 dark:hover:bg-white/5 transition"
|
|
>
|
|
<span class="text-gray-500 dark:text-gray-400 text-[10px] uppercase font-sans font-medium">
|
|
Sites
|
|
</span>
|
|
<span class="font-bold text-sm text-gray-900 dark:text-white">{@sites_count}</span>
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%= if @has_subscribers do %>
|
|
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
|
|
<div class="flex items-center gap-1.5 px-2.5 py-1.5">
|
|
<span class="text-gray-500 dark:text-gray-400 text-[10px] uppercase font-sans font-medium">
|
|
Subs
|
|
</span>
|
|
<span class="font-bold text-sm text-gray-900 dark:text-white">
|
|
{format_number(@summary.subscribers.total)}
|
|
</span>
|
|
<span class="text-gray-400 dark:text-gray-500">
|
|
{format_mrr(@summary.subscribers.total_mrr)}/mo
|
|
</span>
|
|
</div>
|
|
|
|
<%= if mrr_at_risk_positive?(@impact_summary.mrr_at_risk) do %>
|
|
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
|
|
<div class="flex items-center gap-1.5 px-2.5 py-1.5 bg-red-100/80 dark:bg-red-900/30 rounded">
|
|
<span class="text-red-500 dark:text-red-400 text-[10px] uppercase font-sans font-medium">
|
|
At Risk
|
|
</span>
|
|
<span class="font-bold text-sm text-red-600 dark:text-red-400">
|
|
{format_mrr(@impact_summary.mrr_at_risk)}
|
|
</span>
|
|
<%= if @impact_summary.subscribers_affected > 0 do %>
|
|
<span class="text-red-500 dark:text-red-400">
|
|
{format_number(@impact_summary.subscribers_affected)} subs
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<%!-- Insights count --%>
|
|
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
|
|
<.link
|
|
navigate={~p"/insights"}
|
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded hover:bg-gray-200/50 dark:hover:bg-white/5 transition"
|
|
>
|
|
<span class="text-gray-500 dark:text-gray-400 text-[10px] uppercase font-sans font-medium">
|
|
Insights
|
|
</span>
|
|
<span class="font-bold text-sm text-gray-900 dark:text-white">
|
|
{@summary.insights.critical + @summary.insights.warning + @summary.insights.info}
|
|
</span>
|
|
<span :if={@summary.insights.critical > 0} class="text-red-500 text-[10px]">
|
|
{@summary.insights.critical}c
|
|
</span>
|
|
<span :if={@summary.insights.warning > 0} class="text-yellow-500 text-[10px]">
|
|
{@summary.insights.warning}w
|
|
</span>
|
|
</.link>
|
|
</div>
|
|
|
|
<%!-- ═══════════════════════════════════════════════
|
|
ACTIVE ISSUES — merged incidents + alerts
|
|
═══════════════════════════════════════════════ --%>
|
|
<%= if @active_incidents != [] || @active_alerts != [] do %>
|
|
<div class="mt-5">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">
|
|
{t("Active Issues")}
|
|
</h2>
|
|
<span class="font-mono text-xs text-red-500 dark:text-red-400">
|
|
{length(@active_incidents) + @total_alert_count}
|
|
</span>
|
|
</div>
|
|
|
|
<%!-- Incidents (highest priority) --%>
|
|
<%= if @active_incidents != [] do %>
|
|
<div class="overflow-hidden rounded-md border border-red-200 dark:border-red-800/50 mb-2">
|
|
<table class="min-w-full">
|
|
<tbody class="divide-y divide-red-100 dark:divide-red-900/30">
|
|
<tr
|
|
:for={incident <- Enum.take(@active_incidents, 10)}
|
|
class={[
|
|
"group",
|
|
incident_severity_classes(incident.mrr_at_risk)
|
|
]}
|
|
>
|
|
<td class="w-1 p-0">
|
|
<%!-- severity bar --%>
|
|
<div class={[
|
|
"w-1 h-full min-h-[2.5rem]",
|
|
cond do
|
|
decimal_to_float(incident.mrr_at_risk) >= 1000 -> "bg-red-500"
|
|
decimal_to_float(incident.mrr_at_risk) >= 100 -> "bg-orange-500"
|
|
decimal_to_float(incident.mrr_at_risk) > 0 -> "bg-yellow-500"
|
|
true -> "bg-gray-400"
|
|
end
|
|
]} />
|
|
</td>
|
|
<td class="px-3 py-1.5">
|
|
<div class="flex items-center gap-2">
|
|
<.icon
|
|
name="hero-exclamation-triangle"
|
|
class="h-3.5 w-3.5 text-red-500 flex-shrink-0"
|
|
/>
|
|
<.link
|
|
navigate={~p"/devices/#{incident.device_id}"}
|
|
class="text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
|
>
|
|
{incident.device_name}
|
|
</.link>
|
|
<span
|
|
:if={incident.site_name}
|
|
class="text-[10px] text-gray-400 dark:text-gray-500"
|
|
>
|
|
@ {incident.site_name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right whitespace-nowrap">
|
|
<div class="flex items-center justify-end gap-3 font-mono text-xs">
|
|
<span class="text-red-600 dark:text-red-400 font-semibold" title="Duration">
|
|
{format_duration(incident.duration_seconds)}
|
|
</span>
|
|
<%= if incident.subscribers_affected > 0 do %>
|
|
<span
|
|
class="text-amber-600 dark:text-amber-400"
|
|
title="Subscribers affected"
|
|
>
|
|
{format_number(incident.subscribers_affected)}s
|
|
</span>
|
|
<% end %>
|
|
<%= if mrr_at_risk_positive?(incident.mrr_at_risk) do %>
|
|
<span class="text-amber-600 dark:text-amber-400" title="MRR at risk">
|
|
{format_mrr(incident.mrr_at_risk)}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Alerts table --%>
|
|
<%= if @active_alerts != [] do %>
|
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10">
|
|
<table class="min-w-full">
|
|
<thead class="bg-gray-50/80 dark:bg-gray-800/80">
|
|
<tr>
|
|
<th class="w-1 p-0"></th>
|
|
<th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Device")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Alert")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Impact")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("When")}
|
|
</th>
|
|
<th class="px-3 py-1.5 w-12"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-800/50">
|
|
<tr
|
|
:for={alert <- @active_alerts}
|
|
id={"alert-#{alert.id}"}
|
|
class="hover:bg-gray-50 dark:hover:bg-white/5 group"
|
|
>
|
|
<td class="w-1 p-0">
|
|
<div class={[
|
|
"w-1 h-full min-h-[2.25rem]",
|
|
cond do
|
|
alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] &&
|
|
alert.gaiia_impact["total_subscribers"] > 10 ->
|
|
"bg-red-500"
|
|
|
|
alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] &&
|
|
alert.gaiia_impact["total_subscribers"] > 0 ->
|
|
"bg-orange-500"
|
|
|
|
true ->
|
|
"bg-yellow-500"
|
|
end
|
|
]} />
|
|
</td>
|
|
<td class="px-3 py-1.5">
|
|
<div class="flex items-center gap-1.5">
|
|
<.link
|
|
navigate={~p"/devices/#{alert.device.id}"}
|
|
class="text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
|
>
|
|
{alert.device.name}
|
|
</.link>
|
|
<span
|
|
:if={alert.device.ip_address}
|
|
class="font-mono text-[11px] text-gray-400 dark:text-gray-500"
|
|
>
|
|
{alert.device.ip_address}
|
|
</span>
|
|
</div>
|
|
<span
|
|
:if={alert.device.site}
|
|
class="text-[10px] text-gray-400 dark:text-gray-500"
|
|
>
|
|
{alert.device.site.name}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
|
{alert.message}
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right whitespace-nowrap">
|
|
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
|
|
<span class="font-mono text-xs text-amber-600 dark:text-amber-400">
|
|
{alert.gaiia_impact["total_subscribers"]}s
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right whitespace-nowrap">
|
|
<.timestamp
|
|
datetime={alert.triggered_at}
|
|
timezone={@timezone}
|
|
class="font-mono text-[11px] text-gray-400 dark:text-gray-500"
|
|
/>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right">
|
|
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
|
|
<button
|
|
type="button"
|
|
phx-click="acknowledge_alert"
|
|
phx-value-id={alert.id}
|
|
class="rounded px-1.5 py-0.5 text-[10px] font-medium text-gray-500 ring-1 ring-inset ring-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:ring-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-200 opacity-0 group-hover:opacity-100 transition"
|
|
>
|
|
ACK
|
|
</button>
|
|
<% else %>
|
|
<span class="text-[10px] text-blue-500 dark:text-blue-400 font-mono">
|
|
ACK
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<%= if @total_alert_count > 20 do %>
|
|
<div class="border-t border-gray-100 dark:border-white/5 px-3 py-1.5 bg-gray-50/50 dark:bg-gray-800/30">
|
|
<.link
|
|
navigate={~p"/alerts"}
|
|
class="text-[10px] font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
+{@total_alert_count - 20} more alerts →
|
|
</.link>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- ═══════════════════════════════════════════════
|
|
SITE HEALTH TABLE
|
|
═══════════════════════════════════════════════ --%>
|
|
<%= if @current_scope.organization.use_sites && @site_impact_summaries != [] do %>
|
|
<div class="mt-5">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("Site Health")}
|
|
</h2>
|
|
<.link
|
|
navigate={~p"/sites"}
|
|
class="text-[10px] font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
>
|
|
all sites →
|
|
</.link>
|
|
</div>
|
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10">
|
|
<table class="min-w-full">
|
|
<thead class="bg-gray-50/80 dark:bg-gray-800/80">
|
|
<tr>
|
|
<th class="px-3 py-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Site")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Devices")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Down")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("QoE")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Subs")}
|
|
</th>
|
|
<th class="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{t("Affected")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-800/50">
|
|
<tr
|
|
:for={site <- @site_impact_summaries}
|
|
class={[
|
|
"hover:bg-gray-50 dark:hover:bg-white/5",
|
|
if(site.devices_down > 0, do: "bg-red-50/50 dark:bg-red-950/10", else: "")
|
|
]}
|
|
>
|
|
<td class="px-3 py-1.5">
|
|
<.link
|
|
navigate={~p"/sites/#{site.site_id}"}
|
|
class="flex items-center gap-1.5 text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
|
>
|
|
<span class={[
|
|
"h-2 w-2 rounded-full flex-shrink-0",
|
|
site_health_dot(site.site_health)
|
|
]}>
|
|
</span>
|
|
{site.name}
|
|
</.link>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
{site.device_count}
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right font-mono text-xs">
|
|
<%= if site.devices_down > 0 do %>
|
|
<span class="text-red-600 dark:text-red-400 font-bold">
|
|
{site.devices_down}
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-300 dark:text-gray-600">0</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right font-mono text-xs">
|
|
<%= if site.avg_qoe do %>
|
|
<span class={
|
|
cond do
|
|
site.avg_qoe < 2.0 -> "text-red-600 dark:text-red-400"
|
|
site.avg_qoe < 4.0 -> "text-yellow-600 dark:text-yellow-400"
|
|
true -> "text-green-600 dark:text-green-400"
|
|
end
|
|
}>
|
|
{format_qoe(site.avg_qoe)}
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-300 dark:text-gray-600">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
<%= if site.subscribers do %>
|
|
{site.subscribers}
|
|
<% else %>
|
|
<span class="text-gray-300 dark:text-gray-600">—</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-3 py-1.5 text-right font-mono text-xs">
|
|
<%= if site.subscribers_affected > 0 do %>
|
|
<span class="text-red-600 dark:text-red-400 font-bold">
|
|
{format_number(site.subscribers_affected)}
|
|
</span>
|
|
<% else %>
|
|
<span class="text-gray-300 dark:text-gray-600">—</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- ═══════════════════════════════════════════════
|
|
INSIGHTS — actionable, grouped by urgency
|
|
═══════════════════════════════════════════════ --%>
|
|
<%= if @insights != [] do %>
|
|
<div class="mt-5">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("Insights")}
|
|
</h2>
|
|
<div class="flex items-center gap-0.5">
|
|
<.link
|
|
patch={~p"/dashboard"}
|
|
class={[
|
|
"rounded px-1.5 py-0.5 text-[10px] font-medium transition",
|
|
if(is_nil(@insight_source),
|
|
do: "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
|
else: "text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10"
|
|
)
|
|
]}
|
|
>
|
|
all
|
|
</.link>
|
|
<.link
|
|
:for={source <- ~w(preseem snmp gaiia system)}
|
|
patch={~p"/dashboard?insight_source=#{source}"}
|
|
class={[
|
|
"rounded px-1.5 py-0.5 text-[10px] font-medium transition",
|
|
if(@insight_source == source,
|
|
do: "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
|
|
else: "text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10"
|
|
)
|
|
]}
|
|
>
|
|
{source}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<.link
|
|
navigate={~p"/insights"}
|
|
class="text-[10px] font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
>
|
|
all insights →
|
|
</.link>
|
|
</div>
|
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10 divide-y divide-gray-100 dark:divide-white/5">
|
|
<div
|
|
:for={insight <- Enum.take(@insights, 6)}
|
|
id={"insight-#{insight.id}"}
|
|
class="flex items-center justify-between gap-2 px-3 py-2 bg-white dark:bg-gray-800/50 hover:bg-gray-50 dark:hover:bg-white/5 group"
|
|
>
|
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
|
<%!-- Urgency indicator --%>
|
|
<span class={[
|
|
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
|
case insight.urgency do
|
|
"critical" -> "bg-red-500"
|
|
"warning" -> "bg-yellow-500"
|
|
"info" -> "bg-blue-500"
|
|
_ -> "bg-gray-400"
|
|
end
|
|
]} />
|
|
<span class={[
|
|
"text-[10px] font-medium uppercase w-8 flex-shrink-0",
|
|
case insight.urgency do
|
|
"critical" -> "text-red-600 dark:text-red-400"
|
|
"warning" -> "text-yellow-600 dark:text-yellow-400"
|
|
"info" -> "text-blue-600 dark:text-blue-400"
|
|
_ -> "text-gray-500"
|
|
end
|
|
]}>
|
|
{case insight.urgency do
|
|
"critical" -> "CRIT"
|
|
"warning" -> "WARN"
|
|
"info" -> "INFO"
|
|
_ -> "—"
|
|
end}
|
|
</span>
|
|
<span class={[
|
|
"text-[10px] font-mono px-1 py-0.5 rounded flex-shrink-0",
|
|
source_classes(insight.source)
|
|
]}>
|
|
{insight.source}
|
|
</span>
|
|
<.link
|
|
navigate={~p"/insights?source=#{insight.source}"}
|
|
class="text-sm text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 truncate"
|
|
>
|
|
{insight.title}
|
|
</.link>
|
|
</div>
|
|
<div class="flex items-center gap-1 flex-shrink-0">
|
|
<.link
|
|
navigate={~p"/insights?source=#{insight.source}"}
|
|
class="rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 opacity-0 group-hover:opacity-100 transition"
|
|
>
|
|
View
|
|
</.link>
|
|
<button
|
|
type="button"
|
|
phx-click="dismiss_insight"
|
|
phx-value-id={insight.id}
|
|
class="rounded p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-400 opacity-0 group-hover:opacity-100 transition"
|
|
title="Dismiss"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- ═══════════════════════════════════════════════
|
|
NETWORK PULSE + CONFIG CHANGES — side by side
|
|
═══════════════════════════════════════════════ --%>
|
|
<div class="mt-5 grid gap-4 lg:grid-cols-2">
|
|
<%!-- Network Pulse --%>
|
|
<div>
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
|
|
{t("Network Pulse")}
|
|
</h2>
|
|
<div class="rounded-md border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-800/50 divide-y divide-gray-100 dark:divide-white/5">
|
|
<div class="flex items-center justify-between px-3 py-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">Devices monitored</span>
|
|
<span class="font-mono text-sm font-bold text-gray-900 dark:text-white">
|
|
{@device_count}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between px-3 py-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">Online</span>
|
|
<span class="font-mono text-sm text-green-600 dark:text-green-400">{@device_up}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between px-3 py-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">Down</span>
|
|
<span class={[
|
|
"font-mono text-sm",
|
|
if(@device_down > 0,
|
|
do: "text-red-600 dark:text-red-400 font-bold",
|
|
else: "text-gray-400"
|
|
)
|
|
]}>
|
|
{@device_down}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between px-3 py-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">Unknown</span>
|
|
<span class={[
|
|
"font-mono text-sm",
|
|
if(@device_unknown > 0,
|
|
do: "text-yellow-600 dark:text-yellow-400",
|
|
else: "text-gray-400"
|
|
)
|
|
]}>
|
|
{@device_unknown}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between px-3 py-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">Config changes (7d)</span>
|
|
<span class="font-mono text-sm text-gray-900 dark:text-white">
|
|
{length(@recent_config_changes)}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between px-3 py-2">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">Active incidents</span>
|
|
<span class={[
|
|
"font-mono text-sm",
|
|
if(length(@active_incidents) > 0,
|
|
do: "text-red-600 dark:text-red-400 font-bold",
|
|
else: "text-gray-400"
|
|
)
|
|
]}>
|
|
{length(@active_incidents)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Config Changes --%>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Config Changes <span class="font-normal text-gray-400 dark:text-gray-500">(7d)</span>
|
|
</h2>
|
|
<.link
|
|
navigate={~p"/insights"}
|
|
class="text-[10px] font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
>
|
|
all →
|
|
</.link>
|
|
</div>
|
|
<%= if Enum.any?(@recent_config_changes) do %>
|
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10 divide-y divide-gray-100 dark:divide-white/5">
|
|
<%= for change <- @recent_config_changes do %>
|
|
<.link
|
|
navigate={~p"/devices/#{change.device_id}/config-timeline"}
|
|
class="flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-800/50 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<span class={"w-1.5 h-1.5 rounded-full flex-shrink-0 #{config_change_impact_color(change)}"} />
|
|
<span class="text-xs font-medium text-gray-900 dark:text-white truncate">
|
|
{if change.device, do: change.device.name, else: "Unknown"}
|
|
</span>
|
|
<span class="font-mono text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
|
|
{change.change_size}L
|
|
</span>
|
|
<span class="text-[10px] text-gray-400 dark:text-gray-500 truncate">
|
|
{Enum.join(Enum.take(change.sections_changed, 2), ", ")}
|
|
</span>
|
|
<span class="ml-auto flex-shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
|
|
{format_short_datetime(change.changed_at, @timezone)}
|
|
</span>
|
|
</.link>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<div class="rounded-md border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-800/50 px-3 py-6 text-center">
|
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
|
No config changes in 7 days
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- ═══════════════════════════════════════════════
|
|
RECENT ACTIVITY
|
|
═══════════════════════════════════════════════ --%>
|
|
<%= if assigns[:recent_activity] && @recent_activity != [] do %>
|
|
<div class="mt-5">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{t("Recent Activity")}
|
|
</h2>
|
|
<.link
|
|
navigate={~p"/activity"}
|
|
class="text-[10px] font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
>
|
|
all activity →
|
|
</.link>
|
|
</div>
|
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-white/10 divide-y divide-gray-100 dark:divide-white/5">
|
|
<div
|
|
:for={activity <- @recent_activity}
|
|
class="flex items-center gap-2 px-3 py-1.5 bg-white dark:bg-gray-800/50"
|
|
>
|
|
<span class="h-1 w-1 rounded-full bg-blue-400 dark:bg-blue-500 flex-shrink-0" />
|
|
<p class="text-xs text-gray-700 dark:text-gray-300 min-w-0 flex-1 truncate">
|
|
{activity.summary}
|
|
</p>
|
|
<span class="flex-shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
|
|
{format_short_datetime(activity.timestamp, @timezone)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- ═══════════════════════════════════════════════
|
|
FOOTER — Command palette hint
|
|
═══════════════════════════════════════════════ --%>
|
|
<div class="mt-6 flex items-center justify-center border-t border-gray-100 dark:border-white/5 pt-4 pb-2">
|
|
<span class="text-[10px] text-gray-300 dark:text-gray-600">
|
|
<kbd class="px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700 font-mono text-gray-400 dark:text-gray-500">
|
|
⌘K
|
|
</kbd>
|
|
<span class="ml-1">to search</span>
|
|
<span class="mx-2">·</span>
|
|
<.link navigate={~p"/devices/new"} class="hover:text-gray-500 dark:hover:text-gray-400">
|
|
+ device
|
|
</.link>
|
|
<span class="mx-2">·</span>
|
|
<.link navigate={~p"/trace"} class="hover:text-gray-500 dark:hover:text-gray-400">
|
|
trace
|
|
</.link>
|
|
<span class="mx-2">·</span>
|
|
<.link navigate={~p"/network-map"} class="hover:text-gray-500 dark:hover:text-gray-400">
|
|
map
|
|
</.link>
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.authenticated>
|