From 69297f1ac098c0192b869c0cd71f65cff700c2883edd8ea89a461b5b42fc9840 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sat, 18 Apr 2026 07:19:31 -0400 Subject: [PATCH] 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) Signed-off-by: Tyler J King --- .../live/artifact_live/index.ex | 90 ++++++++++++++ .../live/ceremony_live/index.ex | 98 +++++++++++++++ .../guildhall_web_web/live/dashboard_live.ex | 117 ++++++++++++++++++ .../lib/guildhall_web_web/router.ex | 9 +- 4 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 apps/guildhall_web/lib/guildhall_web_web/live/artifact_live/index.ex create mode 100644 apps/guildhall_web/lib/guildhall_web_web/live/ceremony_live/index.ex create mode 100644 apps/guildhall_web/lib/guildhall_web_web/live/dashboard_live.ex 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""" +
+
+ <.link navigate={~p"/"} class="text-sm text-blue-600 underline">← Dashboard +

Governed Artifacts

+

{length(@artifacts)} artifacts registered.

+
+ + + + + + + + + + + + + <% for_artifact = fn artifact -> + {label, klass} = drift_summary(artifact) + %{label: label, klass: klass, artifact: artifact} + end %> + + <% row = for_artifact.(a) %> + + + + + + + + + + +
NameTypeTierCIDDrift
{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. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ArtifactTypeTierCeremony IDActorOpened
{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""" +
+
+

Guildhall

+

Ceremony orchestrator + governance UI

+
+ +
+
+
Artifacts
+
{@artifact_count}
+
+
+
Healthy
+
{@healthy_count}
+
+
+
Drifted
+
{@drifted_count}
+
+
+ + + +
+

Recent verifications

+ + + + + + + + + + + + + + + + + + + +
WhenArtifactLayerVerdictVerifier
{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