diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/artifact_live/index.ex b/apps/guildhall_web/lib/guildhall_web_web/live/artifact_live/index.ex
new file mode 100644
index 0000000..22f2f60
--- /dev/null
+++ b/apps/guildhall_web/lib/guildhall_web_web/live/artifact_live/index.ex
@@ -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"""
+
+
+
+
+
+
+ | Name |
+ Type |
+ Tier |
+ CID |
+ Drift |
+
+
+
+ <% for_artifact = fn artifact ->
+ {label, klass} = drift_summary(artifact)
+ %{label: label, klass: klass, artifact: artifact}
+ end %>
+
+ <% row = for_artifact.(a) %>
+ | {row.artifact.artifact_name} |
+ {row.artifact.artifact_type} |
+ {row.artifact.tier} |
+ {truncate_cid(row.artifact.cid)} |
+ {row.label} |
+
+
+
+ No governed artifacts. Run mix run apps/guildhall_ops_db/priv/repo/seeds.exs.
+ |
+
+
+
+
+ """
+ end
+end
diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/ceremony_live/index.ex b/apps/guildhall_web/lib/guildhall_web_web/live/ceremony_live/index.ex
new file mode 100644
index 0000000..3ddbe6b
--- /dev/null
+++ b/apps/guildhall_web/lib/guildhall_web_web/live/ceremony_live/index.ex
@@ -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"""
+
+
+ <.link navigate={~p"/"} class="text-sm text-blue-600 underline">← Dashboard
+ Open Ceremonies
+
+ {length(@ceremonies)} ceremon{if length(@ceremonies) == 1, do: "y", else: "ies"} awaiting witnesses.
+
+
+
+
+
+
+ | Artifact |
+ Type |
+ Tier |
+ Ceremony ID |
+ Actor |
+ Opened |
+
+
+
+
+ | {c.governed_artifact.artifact_name} |
+ {c.governed_artifact.artifact_type} |
+ {c.governed_artifact.tier} |
+ {c.ceremony_id} |
+ {c.actor_did} |
+ {Calendar.strftime(c.transitioned_at, "%Y-%m-%d %H:%M")} |
+
+
+ | No open ceremonies. |
+
+
+
+
+ """
+ end
+end
diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/dashboard_live.ex b/apps/guildhall_web/lib/guildhall_web_web/live/dashboard_live.ex
new file mode 100644
index 0000000..d8fbf1e
--- /dev/null
+++ b/apps/guildhall_web/lib/guildhall_web_web/live/dashboard_live.ex
@@ -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"""
+
+
+
+
+
+
Artifacts
+
{@artifact_count}
+
+
+
Healthy
+
{@healthy_count}
+
+
+
Drifted
+
{@drifted_count}
+
+
+
+
+
+
+ Recent verifications
+
+
+
+ | When |
+ Artifact |
+ Layer |
+ Verdict |
+ Verifier |
+
+
+
+
+ | {Calendar.strftime(v.verified_at, "%Y-%m-%d %H:%M:%S")} |
+ {v.governed_artifact && v.governed_artifact.artifact_name} |
+ {v.layer} |
+
+ {v.verdict}
+ |
+ {v.verifier_infrastructure} |
+
+
+
+
+
+ """
+ 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
diff --git a/apps/guildhall_web/lib/guildhall_web_web/router.ex b/apps/guildhall_web/lib/guildhall_web_web/router.ex
index 3b70c05..e73bb6f 100644
--- a/apps/guildhall_web/lib/guildhall_web_web/router.ex
+++ b/apps/guildhall_web/lib/guildhall_web_web/router.ex
@@ -17,11 +17,8 @@ defmodule GuildhallWeb.Router do
scope "/", GuildhallWeb do
pipe_through :browser
- get "/", PageController, :home
+ live "/", DashboardLive, :index
+ live "/ceremonies", CeremonyLive.Index, :index
+ live "/artifacts", ArtifactLive.Index, :index
end
-
- # Other scopes may use custom stacks.
- # scope "/api", GuildhallWeb do
- # pipe_through :api
- # end
end