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:
parent
4d9acf96d8
commit
69297f1ac0
4 changed files with 308 additions and 6 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
117
apps/guildhall_web/lib/guildhall_web_web/live/dashboard_live.ex
Normal file
117
apps/guildhall_web/lib/guildhall_web_web/live/dashboard_live.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue