if Code.ensure_loaded?(Phoenix.LiveView) do
defmodule ObanPowertools.Web.ForensicsLive do
@moduledoc false
use Phoenix.LiveView
alias ObanPowertools.Forensics
alias ObanPowertools.Web.{ControlPlanePresenter, LiveAuth}
@allowed_params ~w(resource_type resource_id workflow_id step incident_fingerprint view)
@impl true
def mount(_params, _session, socket) do
with {:ok, socket} <-
LiveAuth.authorize_page(socket, :view_forensics, %{type: :page, id: "forensics"}) do
{:ok,
socket
|> assign(:bundle, Forensics.bundle(%{}, repo: repo()))
|> assign(:selectors, %{})}
else
{:error, socket} -> {:ok, socket}
end
end
@impl true
def handle_params(params, _uri, socket) do
selectors =
params
|> Map.take(@allowed_params)
|> Forensics.selectors()
{:noreply,
socket
|> assign(:selectors, selectors)
|> assign(:bundle, Forensics.bundle(selectors, repo: repo()))}
end
@impl true
def render(assigns) do
~H"""
<div class="space-y-6 p-6">
<div>
<h1 class="text-2xl font-semibold">Forensics</h1>
<p class="text-sm text-zinc-600">
One diagnosis-first forensic story for Powertools-native workflow and Lifeline investigations. Limiter and cron context remains supporting evidence, while audit follow-up stays Inspection only.
</p>
</div>
<p class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<%= LiveAuth.page_read_only_banner(:forensics) %>
</p>
<div class="rounded-lg border bg-white p-4">
<h2 class="text-base font-semibold">Diagnosis Summary</h2>
<p class="mt-2 text-sm text-zinc-600">
Subject: <%= @bundle.subject.label %> (<%= @bundle.subject.entry_surface || "unknown" %>)
</p>
<p class="mt-1 text-sm text-zinc-600">
Current diagnosis: <%= @bundle.diagnosis_summary.current %>
</p>
<p class="mt-1 text-sm text-zinc-600"><%= @bundle.diagnosis_summary.detail %></p>
</div>
<div class="rounded-lg border bg-white p-4">
<%= if @bundle[:runbook_entry] do %>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold"><%= @bundle.runbook_entry.title %></h2>
<p class="mt-1 text-xs text-zinc-500">Advisory runbook guidance from the current evidence bundle.</p>
</div>
<a
:if={@bundle.runbook_entry.evidence_path}
href={@bundle.runbook_entry.evidence_path}
class="rounded border border-indigo-200 px-3 py-2 text-sm text-indigo-700"
>
Evidence link
</a>
</div>
<div :if={continuity = runbook_continuity(@bundle)} class="mt-4 rounded border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-semibold">Latest runbook continuity</p>
<p class="mt-1 text-sm text-zinc-600"><strong>Diagnosis:</strong> <%= continuity_diagnosis(@bundle, continuity) %></p>
<p class="mt-1 text-sm text-zinc-600"><strong>Legal next path:</strong> <%= continuity_legal_next_path(continuity) %></p>
<p class="mt-1 text-sm text-zinc-600"><strong>Venue:</strong> <%= continuity_venue(continuity) %></p>
<p class="mt-1 text-sm text-zinc-600"><strong>Attempt state:</strong> <%= continuity_attempt_state(continuity) %></p>
<p class="mt-1 text-sm text-zinc-600">
<strong>host-owned follow-up status:</strong> <%= continuity_host_follow_up_status(continuity) %>
</p>
<p :if={detail = continuity_host_follow_up_detail(continuity)} class="mt-1 text-xs text-zinc-500">
<%= detail %>
</p>
<p class="mt-1 text-sm text-zinc-600"><strong>Reason:</strong> <%= continuity_reason(continuity) %></p>
<p class="mt-2 text-sm text-zinc-600">
<strong>Evidence link:</strong>
<a
:if={@bundle.runbook_entry.evidence_path}
href={@bundle.runbook_entry.evidence_path}
class="text-indigo-700 underline"
>
Open forensic evidence
</a>
<span :if={is_nil(@bundle.runbook_entry.evidence_path)}>No evidence link available</span>
</p>
<p class="mt-1 text-sm text-zinc-600">
<strong>Audit follow-up:</strong>
<a :if={path = continuity_audit_follow_up_path(@bundle)} href={path} class="text-indigo-700 underline">
Open in Audit
</a>
<span :if={is_nil(continuity_audit_follow_up_path(@bundle))}>No audit follow-up available</span>
</p>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded border bg-slate-50 p-3">
<h3 class="text-sm font-semibold">Diagnosis state</h3>
<p class="mt-1 text-sm text-zinc-600"><%= @bundle.runbook_entry.diagnosis_state %></p>
</div>
<div class="rounded border bg-slate-50 p-3">
<h3 class="text-sm font-semibold">Why it matters now</h3>
<p class="mt-1 text-sm text-zinc-600"><%= @bundle.runbook_entry.why_now %></p>
</div>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold">Prerequisites</h3>
<div class="mt-2 space-y-2">
<div :for={item <- @bundle.runbook_entry.prerequisites} class="rounded border bg-slate-50 p-3">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium"><%= item.label %></span>
<span class="rounded border px-2 py-1 text-xs text-zinc-600"><%= item.state %></span>
</div>
<p class="mt-1 text-sm text-zinc-600"><%= item.detail %></p>
</div>
</div>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold">Cautions</h3>
<div class="mt-2 space-y-2">
<div :for={item <- @bundle.runbook_entry.cautions} class={caution_class(item)}>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium"><%= item.label %></span>
<span class="rounded border px-2 py-1 text-xs"><%= item.severity %></span>
</div>
<p class="mt-1 text-sm"><%= item.detail %></p>
</div>
</div>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold">Recommended order</h3>
<div class="mt-2 space-y-2">
<div
:for={item <- @bundle.runbook_entry.ordered_next_paths}
data-runbook-ownership={item.ownership}
data-runbook-variant={follow_up_variant(item)}
class={runbook_path_class(item)}
>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded border px-2 py-1 text-xs"><%= item.ownership %></span>
<span class="text-xs text-zinc-500"><%= item.venue %></span>
<span class="text-xs text-zinc-500"><%= item.intent %></span>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="text-sm font-medium"><%= item.order %>. <%= item.label %></span>
<a :if={item.path} href={item.path} class={runbook_path_link_class(item)}>
Open path
</a>
</div>
</div>
</div>
<div class="mt-3 space-y-1 text-xs text-zinc-600">
<p><%= ControlPlanePresenter.runbook_ownership_label("Powertools-native") %></p>
<p><%= ControlPlanePresenter.runbook_ownership_label("Oban Web bridge") %></p>
<p><%= ControlPlanePresenter.runbook_ownership_label("host-owned follow-up") %></p>
</div>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold">Unsupported boundaries</h3>
<div class="mt-2 space-y-2">
<p :for={boundary <- @bundle.runbook_entry.unsupported_boundaries} class="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<%= boundary %>
</p>
</div>
</div>
<div class="mt-4 rounded border bg-slate-50 p-3">
<h3 class="text-sm font-semibold">Evidence completeness</h3>
<p class="mt-1 text-sm text-zinc-600">
<%= ControlPlanePresenter.forensic_completeness_label(@bundle.runbook_entry.evidence_completeness.state) %>
</p>
<p class="mt-1 text-sm text-zinc-600"><%= completeness_details(@bundle.runbook_entry.evidence_completeness) %></p>
</div>
<% else %>
<h2 class="text-base font-semibold">Open runbook entry</h2>
<p class="mt-2 text-sm text-zinc-600">
Runbook guidance is unavailable because the evidence bundle could not be assembled. Refresh the page, then open the forensic timeline for the same resource.
</p>
<% end %>
</div>
<div class="rounded-lg border bg-white p-4">
<h2 class="text-base font-semibold">Timeline</h2>
<%= if @bundle.chronology == [] do %>
<p class="mt-2 text-sm text-zinc-600">
No chronology evidence is available yet. <%= completeness_details(@bundle.completeness) %>
</p>
<% else %>
<div class="mt-3 space-y-3">
<div :for={item <- @bundle.chronology} class="rounded border bg-slate-50 p-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="font-medium"><%= item.label %></p>
<p class="text-xs text-zinc-500">
<%= item.source_family %> • <%= ControlPlanePresenter.forensic_provenance_label(item.strength) %>
</p>
</div>
<a
:if={item.resource_type && item.resource_id}
href={audit_follow_up_path(item)}
class="text-sm text-indigo-700 underline"
>
Audit follow-up
</a>
</div>
<p class="mt-2 text-sm text-zinc-600"><%= item.notes || "No additional notes." %></p>
<p class="mt-1 text-xs text-zinc-500">
<%= format_timestamp(item.occurred_at) %> • <%= item.resource_type %>:<%= item.resource_id %>
</p>
</div>
</div>
<% end %>
</div>
<div class="rounded-lg border bg-white p-4">
<h2 class="text-base font-semibold">Related Evidence</h2>
<div class="mt-3 space-y-3">
<div :for={item <- @bundle.related_evidence} class="rounded border bg-slate-50 p-3">
<p class="font-medium"><%= item.title %></p>
<p class="mt-1 text-sm text-zinc-600"><%= item.summary %></p>
<p class="mt-1 text-xs text-zinc-500">
<%= ControlPlanePresenter.forensic_provenance_label(item.provenance) %>
</p>
</div>
</div>
</div>
<div class="rounded-lg border bg-white p-4">
<h2 class="text-base font-semibold">Linked Resources</h2>
<div class="mt-3 space-y-2 text-sm">
<div :for={item <- @bundle.linked_resources}>
<a href={item.path} class="text-indigo-700 underline"><%= item.label %></a>
<span class="text-zinc-500"> — <%= item.venue %></span>
</div>
</div>
</div>
<div class="rounded-lg border bg-white p-4">
<h2 class="text-base font-semibold">Legal Next Paths</h2>
<div class="mt-3 space-y-2 text-sm">
<div :for={item <- @bundle.legal_next_paths}>
<a href={item.path} class="text-indigo-700 underline"><%= item.label %></a>
<span class="text-zinc-500"> — <%= item.venue %></span>
</div>
</div>
</div>
<div class="rounded-lg border bg-white p-4">
<h2 class="text-base font-semibold">Evidence Completeness</h2>
<p class="mt-2 text-sm text-zinc-600">
<%= ControlPlanePresenter.forensic_completeness_label(@bundle.completeness.state) %>
</p>
<p class="mt-1 text-sm text-zinc-600"><%= completeness_details(@bundle.completeness) %></p>
</div>
<div class="rounded-lg border bg-slate-50 p-4 text-xs text-zinc-500">
Selectors:
<%= @selectors |> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Enum.map_join(", ", fn {key, value} -> "#{key}=#{value}" end) %>
</div>
</div>
"""
end
defp audit_follow_up_path(item) do
[
{"resource_type", item.resource_type},
{"resource_id", item.resource_id},
{"event_type", item.event_type}
]
|> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end)
|> URI.encode_query()
|> then(&"/ops/jobs/audit?#{&1}")
end
defp format_timestamp(nil), do: "Unknown"
defp format_timestamp(%NaiveDateTime{} = timestamp) do
timestamp
|> DateTime.from_naive!("Etc/UTC")
|> format_timestamp()
end
defp format_timestamp(%DateTime{} = timestamp) do
Calendar.strftime(timestamp, "%Y-%m-%d %H:%M:%S UTC")
end
defp completeness_details(%{details: details}), do: details
defp completeness_details(_), do: "No completeness details available."
defp caution_class(%{severity: :warning}),
do: "rounded border border-amber-200 bg-amber-50 p-3 text-amber-800"
defp caution_class(_item), do: "rounded border bg-slate-50 p-3 text-zinc-600"
defp runbook_path_class(item) do
case ControlPlanePresenter.follow_up_render_variant(item) do
:native_primary -> "rounded border border-indigo-200 bg-indigo-50 p-3"
:bridge_guidance -> "rounded border border-slate-200 bg-white p-3"
:host_guidance -> "rounded border border-amber-200 bg-amber-50 p-3"
end
end
defp runbook_path_link_class(item) do
case ControlPlanePresenter.follow_up_render_variant(item) do
:native_primary -> "rounded bg-indigo-700 px-3 py-2 text-sm text-white"
_guidance -> "text-sm text-indigo-700 underline"
end
end
defp runbook_continuity(bundle) do
case get_in(bundle, [:subject, :continuity]) || get_in(bundle, [:subject, "continuity"]) do
%{} = continuity -> continuity
_missing -> nil
end
end
defp continuity_attempt_state(continuity) do
Map.get(continuity, "attempt_state") || Map.get(continuity, :attempt_state) || "unknown"
end
defp continuity_diagnosis(bundle, continuity) do
Map.get(continuity, "diagnosis_state") ||
Map.get(continuity, :diagnosis_state) ||
get_in(bundle, [:runbook_entry, :diagnosis_state]) ||
"unknown"
end
defp continuity_legal_next_path(continuity) do
intent = Map.get(continuity, "action") || Map.get(continuity, :action) || "investigate"
ownership = follow_up_ownership_label(continuity)
"#{intent} via #{ownership}"
end
defp continuity_venue(continuity) do
Map.get(continuity, "venue") ||
Map.get(continuity, :venue) ||
follow_up_ownership_label(continuity)
end
defp continuity_reason(continuity) do
Map.get(continuity, "reason") || Map.get(continuity, :reason) || "none provided"
end
defp continuity_host_follow_up_status(continuity) do
case Map.get(continuity, "host_follow_up_status") do
nil -> "Host-owned follow-up unavailable"
status -> ControlPlanePresenter.host_follow_up_status_label(status)
end
end
defp continuity_host_follow_up_detail(continuity) do
details = Map.get(continuity, "host_follow_up_details") || %{}
status = Map.get(continuity, "host_follow_up_status")
case status do
"host_owned_follow_up_unconfigured" ->
details["configuration"] || "No host escalation hook configured"
"host_owned_follow_up_callback_failed" ->
details["reason"] || "Host-owned follow-up callback failed"
_other ->
nil
end
end
defp continuity_audit_follow_up_path(bundle) do
[
{"resource_type",
get_in(bundle, [:subject, :resource_type]) || get_in(bundle, [:subject, "resource_type"])},
{"resource_id",
get_in(bundle, [:subject, :resource_id]) || get_in(bundle, [:subject, "resource_id"])}
]
|> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end)
|> case do
[] -> nil
params -> "/ops/jobs/audit?" <> URI.encode_query(params)
end
end
defp follow_up_variant(item) do
item
|> ControlPlanePresenter.follow_up_render_variant()
|> Atom.to_string()
end
defp follow_up_ownership_label(continuity) do
continuity
|> Map.get("ownership")
|> case do
nil ->
continuity
|> Map.get("venue")
|> ControlPlanePresenter.runbook_ownership_label()
ownership ->
ControlPlanePresenter.runbook_ownership_label(ownership)
end
end
defp repo, do: Application.fetch_env!(:oban_powertools, :repo)
end
end