feat(web): minimal LiveView dashboard, ceremonies, artifacts

Three LiveView modules reading from the Ops DB:

- DashboardLive at /: governance overview with artifact count,
  healthy/drifted deployment states, and the five most recent
  verification results. Subscribes to Guildhall.PubSub for
  ceremony:* and posture:* topics.

- CeremonyLive.Index at /ceremonies: lists open ceremonies. Query
  finds the latest custody_transition per artifact and keeps the
  ones where to_state = 'ceremony_open'. PubSub-driven refresh.
  Will integrate with substrate CRD watcher in a future sprint.

- ArtifactLive.Index at /artifacts: lists governed artifacts with
  name, type, tier, truncated CID, and aggregate drift status
  derived from their deployment_states.

Router updated to mount these LiveViews at /, /ceremonies,
/artifacts. Default PageController route removed.

Inline Heex templates (no separate .html.heex files); uses
Tailwind classes from Phoenix 1.8 default CSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-18 07:19:31 -04:00
parent 4d9acf96d8
commit 69297f1ac0
4 changed files with 308 additions and 6 deletions

View file

@ -0,0 +1,90 @@
defmodule GuildhallWeb.ArtifactLive.Index do
@moduledoc """
Lists governed artifacts with type, tier, CID (truncated), and
current deployment drift status.
"""
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Repo, GovernedArtifact}
import Ecto.Query
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Governed Artifacts")
|> assign(:artifacts, list_artifacts())}
end
defp list_artifacts do
from(a in GovernedArtifact,
order_by: [desc: a.created_at],
preload: [:deployment_states]
)
|> Repo.all()
end
defp truncate_cid(nil), do: ""
defp truncate_cid(cid) when byte_size(cid) <= 20, do: cid
defp truncate_cid(cid) do
[prefix, hash] = String.split(cid, ":", parts: 2)
head = String.slice(hash, 0, 12)
"#{prefix}:#{head}"
end
defp drift_summary(artifact) do
states = artifact.deployment_states
cond do
states == [] -> {"", "text-zinc-400"}
Enum.any?(states, &(&1.drift_status == "drift")) -> {"drift", "text-red-600"}
Enum.all?(states, &(&1.drift_status == "match")) -> {"match", "text-emerald-600"}
true -> {"mixed", "text-amber-600"}
end
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-5xl p-6 space-y-4">
<header>
<.link navigate={~p"/"} class="text-sm text-blue-600 underline"> Dashboard</.link>
<h1 class="text-2xl font-semibold mt-2">Governed Artifacts</h1>
<p class="text-sm text-zinc-500">{length(@artifacts)} artifacts registered.</p>
</header>
<table class="w-full text-sm">
<thead class="text-left text-zinc-500">
<tr>
<th class="py-1">Name</th>
<th class="py-1">Type</th>
<th class="py-1">Tier</th>
<th class="py-1">CID</th>
<th class="py-1">Drift</th>
</tr>
</thead>
<tbody>
<% for_artifact = fn artifact ->
{label, klass} = drift_summary(artifact)
%{label: label, klass: klass, artifact: artifact}
end %>
<tr :for={a <- @artifacts} class="border-t border-zinc-100">
<% row = for_artifact.(a) %>
<td class="py-1 font-mono">{row.artifact.artifact_name}</td>
<td class="py-1 text-zinc-600">{row.artifact.artifact_type}</td>
<td class="py-1 uppercase">{row.artifact.tier}</td>
<td class="py-1 font-mono text-xs">{truncate_cid(row.artifact.cid)}</td>
<td class="py-1"><span class={row.klass}>{row.label}</span></td>
</tr>
<tr :if={@artifacts == []}>
<td colspan="5" class="py-6 text-center text-zinc-400">
No governed artifacts. Run <code>mix run apps/guildhall_ops_db/priv/repo/seeds.exs</code>.
</td>
</tr>
</tbody>
</table>
</div>
"""
end
end

View file

@ -0,0 +1,98 @@
defmodule GuildhallWeb.CeremonyLive.Index do
@moduledoc """
Lists open ceremonies. Reads from custody_transitions where
`to_state = "ceremony_open"` and no later transition exists
for the same artifact.
Once the substrate CRD watcher is implemented, this view will
also reflect live CeremonyRequest CRD status.
"""
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Repo, CustodyTransition}
import Ecto.Query
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "ceremony:*")
end
{:ok,
socket
|> assign(:page_title, "Ceremonies")
|> assign(:ceremonies, list_open_ceremonies())}
end
@impl true
def handle_info({:ceremony_status, _id, _status}, socket) do
{:noreply, assign(socket, ceremonies: list_open_ceremonies())}
end
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
defp list_open_ceremonies do
# Find the latest transition per artifact, keep the ones whose
# latest state is ceremony_open.
latest_q =
from ct in CustodyTransition,
group_by: ct.governed_artifact_id,
select: %{
governed_artifact_id: ct.governed_artifact_id,
max_seq: max(ct.sequence_number)
}
from(ct in CustodyTransition,
join: latest in subquery(latest_q),
on:
latest.governed_artifact_id == ct.governed_artifact_id and
latest.max_seq == ct.sequence_number,
where: ct.to_state == "ceremony_open",
order_by: [desc: ct.transitioned_at],
preload: [:governed_artifact]
)
|> Repo.all()
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-4xl p-6 space-y-4">
<header>
<.link navigate={~p"/"} class="text-sm text-blue-600 underline"> Dashboard</.link>
<h1 class="text-2xl font-semibold mt-2">Open Ceremonies</h1>
<p class="text-sm text-zinc-500">
{length(@ceremonies)} ceremon{if length(@ceremonies) == 1, do: "y", else: "ies"} awaiting witnesses.
</p>
</header>
<table class="w-full text-sm">
<thead class="text-left text-zinc-500">
<tr>
<th class="py-1">Artifact</th>
<th class="py-1">Type</th>
<th class="py-1">Tier</th>
<th class="py-1">Ceremony ID</th>
<th class="py-1">Actor</th>
<th class="py-1">Opened</th>
</tr>
</thead>
<tbody>
<tr :for={c <- @ceremonies} class="border-t border-zinc-100">
<td class="py-1 font-mono">{c.governed_artifact.artifact_name}</td>
<td class="py-1 text-zinc-600">{c.governed_artifact.artifact_type}</td>
<td class="py-1 uppercase">{c.governed_artifact.tier}</td>
<td class="py-1 font-mono text-xs">{c.ceremony_id}</td>
<td class="py-1 text-xs">{c.actor_did}</td>
<td class="py-1">{Calendar.strftime(c.transitioned_at, "%Y-%m-%d %H:%M")}</td>
</tr>
<tr :if={@ceremonies == []}>
<td colspan="6" class="py-6 text-center text-zinc-400">No open ceremonies.</td>
</tr>
</tbody>
</table>
</div>
"""
end
end

View file

@ -0,0 +1,117 @@
defmodule GuildhallWeb.DashboardLive do
@moduledoc """
Governance overview landing page. Summarizes artifact counts,
drift status, and recent verifications.
"""
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Repo, GovernedArtifact, DeploymentState, VerificationResult}
import Ecto.Query
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "ceremony:*")
Phoenix.PubSub.subscribe(Guildhall.PubSub, "posture:*")
end
{:ok,
socket
|> assign(:page_title, "Guildhall — Governance Dashboard")
|> load_metrics()}
end
@impl true
def handle_info({:ceremony_status, _id, _status}, socket) do
{:noreply, load_metrics(socket)}
end
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
defp load_metrics(socket) do
socket
|> assign(:artifact_count, Repo.aggregate(GovernedArtifact, :count))
|> assign(:healthy_count, count_by_drift("match"))
|> assign(:drifted_count, count_by_drift("drift"))
|> assign(:recent_verifications, recent_verifications(5))
end
defp count_by_drift(status) do
Repo.aggregate(
from(d in DeploymentState, where: d.drift_status == ^status),
:count
)
end
defp recent_verifications(limit) do
Repo.all(
from v in VerificationResult,
order_by: [desc: v.verified_at],
limit: ^limit,
preload: [:governed_artifact]
)
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-4xl p-6 space-y-6">
<header>
<h1 class="text-2xl font-semibold">Guildhall</h1>
<p class="text-sm text-zinc-500">Ceremony orchestrator + governance UI</p>
</header>
<section class="grid grid-cols-3 gap-4">
<div class="rounded-lg border border-zinc-200 p-4">
<div class="text-xs uppercase text-zinc-500">Artifacts</div>
<div class="text-3xl font-semibold">{@artifact_count}</div>
</div>
<div class="rounded-lg border border-zinc-200 p-4">
<div class="text-xs uppercase text-zinc-500">Healthy</div>
<div class="text-3xl font-semibold text-emerald-600">{@healthy_count}</div>
</div>
<div class="rounded-lg border border-zinc-200 p-4">
<div class="text-xs uppercase text-zinc-500">Drifted</div>
<div class="text-3xl font-semibold text-red-600">{@drifted_count}</div>
</div>
</section>
<nav class="flex gap-3 text-sm">
<.link navigate={~p"/ceremonies"} class="text-blue-600 underline">Ceremonies</.link>
<.link navigate={~p"/artifacts"} class="text-blue-600 underline">Artifacts</.link>
</nav>
<section>
<h2 class="text-lg font-semibold mb-2">Recent verifications</h2>
<table class="w-full text-sm">
<thead class="text-left text-zinc-500">
<tr>
<th class="py-1">When</th>
<th class="py-1">Artifact</th>
<th class="py-1">Layer</th>
<th class="py-1">Verdict</th>
<th class="py-1">Verifier</th>
</tr>
</thead>
<tbody>
<tr :for={v <- @recent_verifications} class="border-t border-zinc-100">
<td class="py-1">{Calendar.strftime(v.verified_at, "%Y-%m-%d %H:%M:%S")}</td>
<td class="py-1">{v.governed_artifact && v.governed_artifact.artifact_name}</td>
<td class="py-1">{v.layer}</td>
<td class="py-1">
<span class={verdict_class(v.verdict)}>{v.verdict}</span>
</td>
<td class="py-1">{v.verifier_infrastructure}</td>
</tr>
</tbody>
</table>
</section>
</div>
"""
end
defp verdict_class("match"), do: "text-emerald-600"
defp verdict_class("drift"), do: "text-red-600"
defp verdict_class(_), do: "text-zinc-500"
end

View file

@ -17,11 +17,8 @@ defmodule GuildhallWeb.Router do
scope "/", GuildhallWeb do scope "/", GuildhallWeb do
pipe_through :browser pipe_through :browser
get "/", PageController, :home live "/", DashboardLive, :index
live "/ceremonies", CeremonyLive.Index, :index
live "/artifacts", ArtifactLive.Index, :index
end end
# Other scopes may use custom stacks.
# scope "/api", GuildhallWeb do
# pipe_through :api
# end
end end