830 lines
40 KiB
Text
830 lines
40 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="insights"
|
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
|
>
|
|
<div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5">
|
|
<div>
|
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
|
{t("Network Insights")}
|
|
</h1>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Proactive network health observations from all sources.")}
|
|
</p>
|
|
</div>
|
|
|
|
<%= if Towerops.Accounts.Scope.superuser?(@current_scope) do %>
|
|
<button
|
|
type="button"
|
|
id="regenerate-insights-btn"
|
|
phx-click="regenerate_insights"
|
|
data-confirm={t("Queue insight regeneration jobs for all sources?")}
|
|
class="inline-flex items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
>
|
|
<.icon name="hero-arrow-path" class="h-4 w-4" />
|
|
{t("Regenerate insights")}
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Filter bar --%>
|
|
<div class="mt-6 flex flex-wrap items-center gap-4 border-b border-gray-200 pb-4 dark:border-white/10">
|
|
<%!-- Status filter tabs --%>
|
|
<nav class="flex space-x-4">
|
|
<.link
|
|
id="filter-active"
|
|
patch={
|
|
~p"/insights?#{build_filter_params(%{source: @filter_source, urgency: @filter_urgency}, %{status: "active"})}"
|
|
}
|
|
class={[
|
|
"rounded-md px-3 py-1.5 text-sm font-medium",
|
|
if(@filter_status == "active",
|
|
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400",
|
|
else:
|
|
"text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
{t("Active")}
|
|
</.link>
|
|
<.link
|
|
id="filter-dismissed"
|
|
patch={
|
|
~p"/insights?#{build_filter_params(%{source: @filter_source, urgency: @filter_urgency}, %{status: "dismissed"})}"
|
|
}
|
|
class={[
|
|
"rounded-md px-3 py-1.5 text-sm font-medium",
|
|
if(@filter_status == "dismissed",
|
|
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400",
|
|
else:
|
|
"text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
{t("Dismissed")}
|
|
</.link>
|
|
</nav>
|
|
|
|
<%!-- Source filter pills --%>
|
|
<div class="flex items-center gap-2 border-l border-gray-200 pl-4 dark:border-white/10">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Source:</span>
|
|
<.link
|
|
id="filter-source-all"
|
|
patch={
|
|
~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{})}"
|
|
}
|
|
class={[
|
|
"rounded-md px-2 py-1 text-xs font-medium",
|
|
if(is_nil(@filter_source),
|
|
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
|
|
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
|
|
)
|
|
]}
|
|
>
|
|
{t("All")}
|
|
</.link>
|
|
<.link
|
|
:for={source <- ~w(preseem gaiia snmp system ai)}
|
|
id={"filter-source-#{source}"}
|
|
patch={
|
|
~p"/insights?#{build_filter_params(%{status: @filter_status, urgency: @filter_urgency}, %{source: source})}"
|
|
}
|
|
class={[
|
|
"rounded-md px-2 py-1 text-xs font-medium",
|
|
if(@filter_source == source,
|
|
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
|
|
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
|
|
)
|
|
]}
|
|
>
|
|
{source_label(source)}
|
|
</.link>
|
|
</div>
|
|
|
|
<%!-- Urgency filter --%>
|
|
<div class="flex items-center gap-2 border-l border-gray-200 pl-4 dark:border-white/10">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Urgency:</span>
|
|
<.link
|
|
id="filter-urgency-all"
|
|
patch={
|
|
~p"/insights?#{build_filter_params(%{status: @filter_status, source: @filter_source}, %{})}"
|
|
}
|
|
class={[
|
|
"rounded-md px-2 py-1 text-xs font-medium",
|
|
if(is_nil(@filter_urgency),
|
|
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
|
|
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
|
|
)
|
|
]}
|
|
>
|
|
{t("All")}
|
|
</.link>
|
|
<.link
|
|
:for={
|
|
{urgency, classes} <- [
|
|
{"critical", "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"},
|
|
{"warning",
|
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"},
|
|
{"info", "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"}
|
|
]
|
|
}
|
|
id={"filter-urgency-#{urgency}"}
|
|
patch={
|
|
~p"/insights?#{build_filter_params(%{status: @filter_status, source: @filter_source}, %{urgency: urgency})}"
|
|
}
|
|
class={[
|
|
"rounded-md px-2 py-1 text-xs font-medium capitalize",
|
|
if(@filter_urgency == urgency,
|
|
do: classes,
|
|
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
|
|
)
|
|
]}
|
|
>
|
|
{urgency}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Bulk actions bar --%>
|
|
<%= if any_selected?(@selected_ids) do %>
|
|
<div
|
|
id="bulk-actions"
|
|
class="mt-4 flex items-center gap-3 rounded-lg bg-indigo-50 px-4 py-3 dark:bg-indigo-900/20"
|
|
>
|
|
<span class="text-sm font-medium text-indigo-700 dark:text-indigo-200">
|
|
{MapSet.size(@selected_ids)} selected
|
|
</span>
|
|
<button
|
|
type="button"
|
|
id="deselect-all-btn"
|
|
phx-click="deselect_all"
|
|
class="text-sm text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
>
|
|
{t("Deselect All")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="bulk-dismiss-btn"
|
|
phx-click="bulk_dismiss"
|
|
class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
>
|
|
{t("Dismiss Selected")}
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Insights list --%>
|
|
<div class="mt-6" id="insights-list">
|
|
<%= if @insights == [] do %>
|
|
<div id="empty-state" class="flex items-center justify-center py-8">
|
|
<div class="card shadow-md border border-gray-200 dark:border-gray-700 dark:border-white/10 max-w-md w-full">
|
|
<div class="items-center text-center">
|
|
<div class="rounded-full bg-amber-100 dark:bg-amber-900/40 p-4 mb-2">
|
|
<.icon
|
|
name="hero-light-bulb-solid"
|
|
class="h-12 w-12 text-amber-500 dark:text-amber-400"
|
|
/>
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{t("No Insights Yet")}
|
|
</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
<%= cond do %>
|
|
<% @filter_status == "dismissed" -> %>
|
|
{t("No dismissed insights.")}
|
|
<% @filter_source != nil -> %>
|
|
No {@filter_source} insights right now.
|
|
<% @filter_urgency != nil -> %>
|
|
No {@filter_urgency} insights right now.
|
|
<% true -> %>
|
|
{t("Connect Preseem or enable SNMP monitoring to get started.")}
|
|
<% end %>
|
|
</p>
|
|
<div class="mt-4 flex gap-2 mt-2">
|
|
<span class="text-sm text-gray-900/50 dark:text-gray-100/50">
|
|
{t("Go to your org settings to connect an integration.")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<%!-- Select all bar --%>
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
id="select-all-btn"
|
|
phx-click="select_all"
|
|
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
|
>
|
|
{t("Select All")}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
:for={insight <- @insights}
|
|
id={"insight-#{insight.id}"}
|
|
class={[
|
|
"rounded-lg border bg-white p-4 transition-shadow hover:shadow-md dark:bg-gray-800/50",
|
|
if(selected?(@selected_ids, insight.id),
|
|
do:
|
|
"border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-600 dark:ring-indigo-800",
|
|
else: "border-gray-200 dark:border-white/10"
|
|
)
|
|
]}
|
|
>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex items-start gap-3">
|
|
<%!-- Checkbox --%>
|
|
<button
|
|
type="button"
|
|
phx-click="toggle_select"
|
|
phx-value-id={insight.id}
|
|
class="mt-0.5 flex-shrink-0"
|
|
>
|
|
<div class={[
|
|
"flex h-5 w-5 items-center justify-center rounded border",
|
|
if(selected?(@selected_ids, insight.id),
|
|
do:
|
|
"border-indigo-600 bg-indigo-600 dark:border-indigo-500 dark:bg-indigo-500",
|
|
else: "border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-700"
|
|
)
|
|
]}>
|
|
<%= if selected?(@selected_ids, insight.id) do %>
|
|
<.icon name="hero-check" class="h-3.5 w-3.5 text-white" />
|
|
<% end %>
|
|
</div>
|
|
</button>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<%!-- Urgency badge --%>
|
|
<span class={[
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
|
urgency_classes(insight.urgency)
|
|
]}>
|
|
{insight.urgency}
|
|
</span>
|
|
|
|
<%!-- Source badge --%>
|
|
<span class={[
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
|
source_classes(insight.source)
|
|
]}>
|
|
{source_label(insight.source)}
|
|
</span>
|
|
|
|
<%!-- AI badge — present on any LLM-enriched insight --%>
|
|
<%= if insight.llm_enriched_at do %>
|
|
<span class="inline-flex items-center rounded-full bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white dark:bg-blue-500">
|
|
AI
|
|
</span>
|
|
<% end %>
|
|
|
|
<%!-- Type badge --%>
|
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
|
{String.replace(insight.type, "_", " ")}
|
|
</span>
|
|
</div>
|
|
|
|
<h3 class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
|
|
{insight.title}
|
|
</h3>
|
|
|
|
<%= if insight.description do %>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{insight.description}
|
|
</p>
|
|
<% end %>
|
|
|
|
<%= if insight.llm_summary do %>
|
|
<div class="mt-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-900 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-100">
|
|
<p class="font-medium">{t("Summary")}</p>
|
|
<p>{insight.llm_summary}</p>
|
|
<%= if insight.recommended_action do %>
|
|
<p class="mt-1">
|
|
<span class="font-semibold">{t("Recommended action:")}</span>
|
|
{insight.recommended_action}
|
|
</p>
|
|
<% end %>
|
|
<p class="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
|
AI-generated
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Affected devices list (from metadata) --%>
|
|
<%= if insight.metadata["device_names"] && length(insight.metadata["device_names"]) > 0 do %>
|
|
<div class="mt-2 rounded-md bg-gray-50 p-3 dark:bg-white/5">
|
|
<p class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
|
{t("Affected devices:")}
|
|
</p>
|
|
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
|
<%= for {name, idx} <- Enum.with_index(insight.metadata["device_names"]) do %>
|
|
<%= if insight.metadata["device_ids"] && Enum.at(insight.metadata["device_ids"], idx) do %>
|
|
<.link
|
|
navigate={
|
|
~p"/devices/#{Enum.at(insight.metadata["device_ids"], idx)}"
|
|
}
|
|
class="inline-flex items-center rounded-md bg-white px-2 py-0.5 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-700/10 hover:bg-indigo-50 dark:bg-indigo-900/20 dark:text-indigo-400 dark:ring-indigo-400/20 dark:hover:bg-indigo-900/40"
|
|
>
|
|
{name || "Unnamed"}
|
|
</.link>
|
|
<% else %>
|
|
<span class="inline-flex items-center rounded-md bg-white px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
|
{name || "Unnamed"}
|
|
</span>
|
|
<% end %>
|
|
<% end %>
|
|
<%= if insight.metadata["count"] && insight.metadata["count"] > length(insight.metadata["device_names"]) do %>
|
|
<span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
|
+{insight.metadata["count"] - length(insight.metadata["device_names"])} more
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%!-- Actionable recommendations --%>
|
|
<%= if insight.source == "gaiia" && insight.metadata["finding_type"] == "untracked" do %>
|
|
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
|
<p class="text-xs font-medium text-blue-800 dark:text-blue-300 mb-2">
|
|
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
|
|
{t("To fix this:")}
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
|
|
}
|
|
class="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-blue-700"
|
|
>
|
|
<.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" />
|
|
{t("Link devices to Gaiia")}
|
|
</.link>
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
|
|
}
|
|
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
|
|
>
|
|
<.icon name="hero-table-cells" class="h-3.5 w-3.5" />
|
|
{t("View comparison report")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.source == "gaiia" && insight.metadata["finding_type"] == "ghost" do %>
|
|
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
|
|
<p class="text-xs font-medium text-amber-800 dark:text-amber-200 mb-2">
|
|
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
|
|
{t("To fix this:")}
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
|
|
}
|
|
class="inline-flex items-center gap-1 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-amber-700"
|
|
>
|
|
<.icon name="hero-trash" class="h-3.5 w-3.5" />
|
|
{t("Remove stale device links")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.source == "gaiia" && insight.metadata["finding_type"] == "mismatch" do %>
|
|
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
|
<p class="text-xs font-medium text-blue-800 dark:text-blue-300 mb-2">
|
|
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
|
|
{t("To fix this:")}
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<.link
|
|
navigate={
|
|
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
|
|
}
|
|
class="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-blue-700"
|
|
>
|
|
<.icon name="hero-table-cells" class="h-3.5 w-3.5" />
|
|
{t("Review conflicting data")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "sector_overload" do %>
|
|
<div class="mt-3 rounded-md border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
|
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-orange-700 dark:text-orange-200">
|
|
{t("Free airtime")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-orange-900 dark:text-orange-100">
|
|
{Float.round(insight.metadata["airtime_remaining_pct"] / 1.0, 1)}%
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-orange-700 dark:text-orange-200">
|
|
{t("Subscribers")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-orange-900 dark:text-orange-100">
|
|
{insight.metadata["subscriber_count"]}
|
|
</span>
|
|
</div>
|
|
<%= if insight.metadata["qoe_score"] do %>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-orange-700 dark:text-orange-200">
|
|
{t("QoE score")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-orange-900 dark:text-orange-100">
|
|
{Float.round(insight.metadata["qoe_score"] / 1.0, 1)}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<%= if insight.metadata["ap_model"] do %>
|
|
<span class="ml-auto text-xs text-orange-700 dark:text-orange-200">
|
|
{insight.metadata["ap_model"]}
|
|
{insight.metadata["ap_firmware"] &&
|
|
"(" <> insight.metadata["ap_firmware"] <> ")"}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "cpe_realign" do %>
|
|
<div class="mt-3 rounded-md border border-rose-200 bg-rose-50 p-3 dark:border-rose-800 dark:bg-rose-900/20">
|
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-rose-700 dark:text-rose-200">
|
|
{t("Signal")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
|
|
{insight.metadata["signal_strength_dbm"]} dBm
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-rose-700 dark:text-rose-200">
|
|
{t("SNR")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
|
|
{insight.metadata["snr_db"]} dB
|
|
</span>
|
|
</div>
|
|
<%= if insight.metadata["tx_rate"] && insight.metadata["rx_rate"] do %>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-rose-700 dark:text-rose-200">
|
|
{t("TX/RX rate")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
|
|
{insight.metadata["tx_rate"]}/{insight.metadata["rx_rate"]} Mbps
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<%= if insight.metadata["distance_m"] do %>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-rose-700 dark:text-rose-200">
|
|
{t("Distance")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
|
|
{insight.metadata["distance_m"]} m
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<%= if insight.metadata["cpe_hostname"] do %>
|
|
<span class="ml-auto text-xs text-rose-700 dark:text-rose-200">
|
|
{insight.metadata["cpe_hostname"]}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "optical_rx_low" do %>
|
|
<div class="mt-3 rounded-md border border-cyan-200 bg-cyan-50 p-3 dark:border-cyan-800 dark:bg-cyan-900/20">
|
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-cyan-700 dark:text-cyan-200">
|
|
{t("Port")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-cyan-900 dark:text-cyan-100">
|
|
{insight.metadata["port"]}
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-cyan-700 dark:text-cyan-200">
|
|
{t("Rx power")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-cyan-900 dark:text-cyan-100">
|
|
{insight.metadata["rx_power_dbm"]} dBm
|
|
</span>
|
|
</div>
|
|
<%= if insight.metadata["tx_power_dbm"] do %>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-cyan-700 dark:text-cyan-200">
|
|
{t("Tx power")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-cyan-900 dark:text-cyan-100">
|
|
{insight.metadata["tx_power_dbm"]} dBm
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<%= if insight.metadata["transceiver_type"] do %>
|
|
<span class="ml-auto text-xs text-cyan-700 dark:text-cyan-200">
|
|
{insight.metadata["transceiver_type"]}
|
|
<%= if insight.metadata["wavelength_nm"] do %>
|
|
· {insight.metadata["wavelength_nm"]} nm
|
|
<% end %>
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "upstream_degradation" do %>
|
|
<div class="mt-3 rounded-md border border-indigo-200 bg-indigo-50 p-3 dark:border-indigo-700 dark:bg-indigo-950/40">
|
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs font-medium text-indigo-700 dark:text-indigo-200">
|
|
{t("Degraded APs")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-indigo-900 dark:text-white">
|
|
{insight.metadata["degraded_ap_count"]} / {insight.metadata[
|
|
"total_ap_count"
|
|
]}
|
|
</span>
|
|
</div>
|
|
<span class="ml-auto rounded-md bg-indigo-600 px-2 py-1 text-xs font-semibold text-white dark:bg-indigo-500">
|
|
{t("Investigate upstream first")}
|
|
</span>
|
|
</div>
|
|
<%= if is_list(insight.metadata["degraded_aps"]) do %>
|
|
<ul class="mt-2 space-y-1 text-xs text-indigo-900 dark:text-indigo-50">
|
|
<li
|
|
:for={ap <- insight.metadata["degraded_aps"]}
|
|
class="flex items-center justify-between gap-2"
|
|
>
|
|
<span class="truncate">
|
|
<%= if ap["device_id"] do %>
|
|
<.link
|
|
navigate={~p"/devices/#{ap["device_id"]}"}
|
|
class="text-indigo-700 underline hover:text-indigo-900 dark:text-indigo-200 dark:hover:text-white"
|
|
>
|
|
{ap["name"] || "Unnamed"}
|
|
</.link>
|
|
<% else %>
|
|
{ap["name"] || "Unnamed"}
|
|
<% end %>
|
|
</span>
|
|
<span class="font-mono text-indigo-800 dark:text-indigo-100">
|
|
QoE {Float.round(ap["qoe_score"] / 1.0, 1)}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "device_overheating" do %>
|
|
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-red-700 dark:text-red-200">
|
|
{t("Hottest sensor")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-red-900 dark:text-red-100">
|
|
{insight.metadata["temperature_c"]} °C
|
|
</span>
|
|
<%= if insight.metadata["sensor_descr"] do %>
|
|
<span class="text-xs text-red-700 dark:text-red-200">
|
|
{insight.metadata["sensor_descr"]}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
<%= if insight.metadata["qoe_score"] do %>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-red-700 dark:text-red-200">
|
|
{t("Linked QoE")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-red-900 dark:text-red-100">
|
|
{Float.round(insight.metadata["qoe_score"] / 1.0, 1)}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<%= if is_number(insight.metadata["temperature_c"]) and insight.metadata["temperature_c"] >= 75 do %>
|
|
<span class="ml-auto rounded-md bg-red-600 px-2 py-1 text-xs font-semibold text-white dark:bg-red-500">
|
|
{t("Above manufacturer spec")}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "own_fleet_channel_conflict" do %>
|
|
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-amber-700 dark:text-amber-200">
|
|
{t("Channel")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-amber-900 dark:text-amber-100">
|
|
{insight.metadata["channel"]}
|
|
<%= if insight.metadata["frequency_mhz"] do %>
|
|
<span class="text-xs font-normal text-amber-700 dark:text-amber-200">
|
|
({insight.metadata["frequency_mhz"]} MHz)
|
|
</span>
|
|
<% end %>
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-amber-700 dark:text-amber-200">
|
|
{t("APs sharing")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-amber-900 dark:text-amber-100">
|
|
{insight.metadata["ap_count"]}
|
|
</span>
|
|
</div>
|
|
<%= if insight.metadata["has_critical_rf"] do %>
|
|
<span class="ml-auto rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-200">
|
|
{t("Critical RF environment detected")}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
<%= if is_list(insight.metadata["aps"]) and insight.metadata["aps"] != [] do %>
|
|
<div class="mt-3 overflow-x-auto">
|
|
<p class="text-xs font-medium text-amber-800 dark:text-amber-200">
|
|
{t("Conflicting APs")}
|
|
</p>
|
|
<table class="mt-1 w-full text-left text-xs text-amber-900 dark:text-amber-100">
|
|
<thead class="text-[11px] font-medium text-amber-700 dark:text-amber-200">
|
|
<tr>
|
|
<th class="py-1 pr-3">{t("AP")}</th>
|
|
<th class="py-1 pr-3">{t("Width")}</th>
|
|
<th class="py-1 pr-3">{t("Noise")}</th>
|
|
<th class="py-1 pr-3">{t("RF score")}</th>
|
|
<th class="py-1 pr-3">{t("CPE SNR")}</th>
|
|
<th class="py-1 pr-3">{t("CPEs")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
:for={ap <- insight.metadata["aps"]}
|
|
class="border-t border-amber-200/50 dark:border-amber-800/40"
|
|
>
|
|
<td class="py-1 pr-3">
|
|
<%= if ap["device_id"] do %>
|
|
<.link
|
|
navigate={~p"/devices/#{ap["device_id"]}"}
|
|
class="underline hover:no-underline"
|
|
>
|
|
{ap["name"] || "Unnamed"}
|
|
</.link>
|
|
<% else %>
|
|
{ap["name"] || "Unnamed"}
|
|
<% end %>
|
|
<%= if ap["rf_severity"] == "critical" do %>
|
|
<span class="ml-1 rounded bg-red-600 px-1 py-0.5 text-[10px] font-semibold text-white dark:bg-red-500">
|
|
crit
|
|
</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="py-1 pr-3 font-mono">
|
|
{ap["channel_width_mhz"] && "#{ap["channel_width_mhz"]} MHz"}
|
|
</td>
|
|
<td class="py-1 pr-3 font-mono">
|
|
{ap["noise_floor_dbm"] && "#{ap["noise_floor_dbm"]} dBm"}
|
|
</td>
|
|
<td class="py-1 pr-3 font-mono">
|
|
{ap["rf_score"] && Float.round(ap["rf_score"] / 1.0, 1)}
|
|
</td>
|
|
<td class="py-1 pr-3 font-mono">
|
|
{ap["avg_cpe_snr_db"] &&
|
|
"#{Float.round(ap["avg_cpe_snr_db"] / 1.0, 1)} dB"}
|
|
</td>
|
|
<td class="py-1 pr-3 font-mono">{ap["cpe_count"]}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if insight.type == "ap_frequency_change" do %>
|
|
<div class="mt-3 rounded-md border border-purple-200 bg-purple-50 p-3 dark:border-purple-800 dark:bg-purple-900/20">
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-purple-700 dark:text-purple-200">
|
|
{t("Current channel")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-purple-900 dark:text-purple-100">
|
|
{insight.metadata["current_channel"]}
|
|
</span>
|
|
</div>
|
|
<.icon
|
|
name="hero-arrow-right"
|
|
class="h-4 w-4 text-purple-500 dark:text-purple-400"
|
|
/>
|
|
<div class="flex flex-col">
|
|
<span class="text-xs text-purple-700 dark:text-purple-200">
|
|
{t("Recommended")}
|
|
</span>
|
|
<span class="font-mono text-base font-semibold text-purple-900 dark:text-purple-100">
|
|
{insight.metadata["recommended_channel"]}
|
|
</span>
|
|
</div>
|
|
<%= if insight.metadata["improvement_db"] do %>
|
|
<span class="ml-auto rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
|
|
{Float.round(insight.metadata["improvement_db"] / 1.0, 1)} dB cleaner
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%= if insight.metadata["top_offenders"] && insight.metadata["top_offenders"] != [] do %>
|
|
<div class="mt-3">
|
|
<p class="text-xs font-medium text-purple-800 dark:text-purple-200">
|
|
{t("Top interferers on current channel")}
|
|
</p>
|
|
<ul class="mt-1 space-y-1">
|
|
<li
|
|
:for={offender <- insight.metadata["top_offenders"]}
|
|
class="flex items-center justify-between gap-2 text-xs text-purple-900 dark:text-purple-100"
|
|
>
|
|
<span class="flex items-center gap-2 truncate">
|
|
<span class="font-mono">{offender["bssid"]}</span>
|
|
<%= if offender["ssid"] do %>
|
|
<span class="truncate text-purple-700 dark:text-purple-200">
|
|
{offender["ssid"]}
|
|
</span>
|
|
<% end %>
|
|
<%= if offender["is_own_fleet"] do %>
|
|
<span class="rounded bg-purple-600 px-1.5 py-0.5 text-[10px] font-semibold text-white dark:bg-purple-500">
|
|
own fleet
|
|
</span>
|
|
<% end %>
|
|
</span>
|
|
<span class="font-mono text-purple-700 dark:text-purple-200">
|
|
{offender["rssi_dbm"]} dBm
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
|
<%!-- Access point --%>
|
|
<%= if insight.preseem_access_point do %>
|
|
<span class="flex items-center gap-1">
|
|
<.icon name="hero-signal" class="h-3.5 w-3.5" />
|
|
{insight.preseem_access_point.name}
|
|
</span>
|
|
<% end %>
|
|
|
|
<%!-- Linked device --%>
|
|
<%= if insight.device do %>
|
|
<.link
|
|
navigate={~p"/devices/#{insight.device.id}"}
|
|
class="flex items-center gap-1 text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
>
|
|
<.icon name="hero-server" class="h-3.5 w-3.5" />
|
|
{insight.device.name || "Device"}
|
|
</.link>
|
|
<% end %>
|
|
|
|
<%!-- Timestamp --%>
|
|
<span class="flex items-center gap-1">
|
|
<.icon name="hero-clock" class="h-3.5 w-3.5" />
|
|
{ToweropsWeb.TimeHelpers.format_datetime(
|
|
insight.inserted_at,
|
|
@current_scope.timezone,
|
|
@current_scope.time_format
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Dismiss button --%>
|
|
<%= if insight.status == "active" do %>
|
|
<button
|
|
type="button"
|
|
phx-click="dismiss"
|
|
phx-value-id={insight.id}
|
|
class="flex-shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white"
|
|
>
|
|
{t("Dismiss")}
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</Layouts.authenticated>
|