towerops/lib/towerops_web/live/insights_live/index.html.heex
Graham McIntire 03f364a956 feat(insights): LLM-powered insight enrichment
Adds an optional plain-language summary and recommended action to every
active insight. A new Oban cron worker runs every 5 minutes, picks up
unenriched insights, and asks the configured LLM to restate the finding
and suggest one specific action grounded in the structured metadata.

- Migration: adds llm_summary, recommended_action, llm_model,
  llm_enriched_at columns to preseem_insights
- Towerops.LLM context with swappable behaviour; real client uses Req
  with the standard Req.Test plug for test isolation
- MockClient + InsightPrompt module (builds chat messages, parses JSON
  with code-fence stripping and a raw-text fallback)
- Worker logs and skips on rate limits / missing key / parse errors so
  insights still render without an AI summary when the LLM is unavailable
- Optional towerops-llm k8s secret with example template; deployment.yaml
  references it as optional in both init and main containers
- UI renders summary + recommended action callout on /insights and on
  the org-level insights page
2026-05-09 16:41:48 -05:00

444 lines
19 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="insights"
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<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>
<%!-- 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)}
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 capitalize",
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}
</.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-300">
{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 bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body 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="card-title 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="card-actions mt-2">
<span class="text-sm text-base-content/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 capitalize",
source_classes(insight.source)
]}>
{insight.source}
</span>
<%!-- 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{if insight.llm_model, do: " · " <> insight.llm_model, else: ""}
</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-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-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 %>
<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>