lib/openagents_web/live/network_status_live.ex

main at 436d37facc96 · 25 KB

defmodule OpenAgentsWeb.NetworkStatusLive do
  @moduledoc """
  The public network status page (#125): anyone can watch the state of the
  fleet — cluster membership and quorum, per-node versions and hot-load
  revisions, and rollouts sweeping node to node — live, without an account.

  Same public posture as `LeaderboardLive` (LEADERBOARD-001): a read-only,
  bounded, content-free projection (STATUS-001) that cannot mount or invoke
  OpenAgents. Renders only `OpenAgentsWeb.UI` primitives (UI-003). Updates arrive over
  PubSub plus a slow tick; the page never requires quorum, the database, or a
  full fleet to render — it is most useful precisely when something is down.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.NetworkStatus
  alias OpenAgents.SCV.Activity
  alias OpenAgentsWeb.UI.Graph

  @tick_ms 5_000
  @events_kept 20

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      :ok = NetworkStatus.subscribe()
      :ok = Activity.subscribe()

      Enum.each(["forge:pushes", "forge:target", "forge:builds", "forge:deploys"], fn topic ->
        Phoenix.PubSub.subscribe(OpenAgents.PubSub, topic)
      end)

      if OpenAgents.Cluster.distributed?(), do: :net_kernel.monitor_nodes(true)
      Process.send_after(self(), :tick, @tick_ms)
    end

    {:ok,
     socket
     |> assign(:page_title, "Network status · OpenAgents")
     |> assign(:events, [])
     |> assign_projection(NetworkStatus.projection())}
  end

  @impl true
  def handle_info({:network_status, projection}, socket) do
    {:noreply, socket |> track_transitions(projection) |> assign_projection(projection)}
  end

  def handle_info({:scv_activity, entries}, socket) do
    projection =
      NetworkStatus.projection(refresh: true)
      |> Map.update("scvs", entries, &merge_scvs(&1, entries))

    {:noreply, assign_projection(socket, projection)}
  end

  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, @tick_ms)
    projection = NetworkStatus.projection(refresh: true)
    {:noreply, socket |> track_transitions(projection) |> assign_projection(projection)}
  end

  def handle_info({:nodeup, _node}, socket) do
    {:noreply, socket |> push_event_line("a node joined the cluster") |> refresh()}
  end

  def handle_info({:nodedown, _node}, socket) do
    {:noreply, socket |> push_event_line("a node left the cluster") |> refresh()}
  end

  # Forge deploy-lane events (#126): each transition becomes a content-free
  # event line and refreshes the projection, so a promote sweeps across the
  # pipeline card and the per-node revisions in real time.
  def handle_info({:forge_push, %{wal_seq: seq}}, socket) do
    {:noreply, socket |> push_event_line("push received (seq #{seq})") |> refresh()}
  end

  def handle_info({:forge_target, %{sha: sha}}, socket) do
    {:noreply,
     socket |> push_event_line("#{short_sha(sha)} promoted as fleet target") |> refresh()}
  end

  def handle_info({:forge_target_status, %{sha: sha, status: status}}, socket) do
    {:noreply, socket |> push_event_line("#{short_sha(sha)} → #{status}") |> refresh()}
  end

  def handle_info({:forge_build_ready, %{sha: sha, modules: modules}}, socket) do
    line = "#{short_sha(sha)} built (#{length(modules)} module#{plural(modules)})"
    {:noreply, socket |> push_event_line(line) |> refresh()}
  end

  def handle_info({:forge_deploy, %{sha: sha, result: result}}, socket) do
    {:noreply, socket |> push_event_line("hot deploy #{short_sha(sha)}: #{result}") |> refresh()}
  end

  def handle_info(_message, socket), do: {:noreply, socket}

  defp refresh(socket), do: assign_projection(socket, NetworkStatus.projection(refresh: true))

  defp assign_projection(socket, projection) do
    # A briefly-cached projection built by a pre-#126 module (mid hot-load)
    # may lack the forge section — normalize rather than crash the page.
    projection =
      projection
      |> Map.put_new("forge", %{
        "state" => "off",
        "target" => nil,
        "recent_targets" => [],
        "recent_deploys" => [],
        "loop" => %{"last_ms" => nil, "median_ms" => nil}
      })
      |> Map.update(
        "scvs",
        Activity.public_projection(),
        &merge_scvs(&1, Activity.public_projection())
      )

    socket
    |> assign(:projection, projection)
    |> assign(:scvs, public_scvs(projection["scvs"]))
    |> assign(:overall, overall(projection))
  end

  defp public_scvs(entries) when is_list(entries) do
    Enum.map(entries, fn entry ->
      %{
        id: entry["id"],
        label: entry["label"],
        status: public_scv_status(entry["status"]),
        weight: entry["weight"],
        tool: entry["tool"],
        text: entry["text"]
      }
    end)
  end

  defp public_scvs(_entries), do: []

  defp merge_scvs(durable_entries, live_entries) do
    entries = Map.new(durable_entries ++ live_entries, fn entry -> {entry["id"], entry} end)

    (live_entries ++ durable_entries)
    |> Enum.map(& &1["id"])
    |> Enum.uniq()
    |> Enum.take(32)
    |> Enum.map(&Map.fetch!(entries, &1))
  end

  defp public_scv_status("running"), do: :running
  defp public_scv_status(_status), do: :idle

  # Version divergence across reachable nodes IS the rollout visualization:
  # when a roll or hot deploy is sweeping the fleet, nodes disagree — surface
  # that as an explicit "rolling" state with progress.
  defp rollout(projection) do
    reachable = Enum.filter(projection["nodes"], & &1["reachable"])

    versions =
      reachable
      |> Enum.map(&{&1["release"], &1["revision"]})
      |> Enum.reject(&(&1 == {nil, nil}))
      |> Enum.uniq()

    case versions do
      [_one] ->
        :steady

      [] ->
        :steady

      _several ->
        newest = reachable |> Enum.map(& &1["revision"]) |> Enum.reject(&is_nil/1) |> List.last()
        on_newest = Enum.count(reachable, &(&1["revision"] == newest))
        {:rolling, on_newest, length(reachable)}
    end
  end

  defp overall(projection) do
    cluster = projection["cluster"]
    unreachable = Enum.count(projection["nodes"], &(!&1["reachable"]))

    cond do
      not cluster["quorum"] and cluster["beam"] > 1 -> {"degraded", "QUORUM AT RISK"}
      unreachable > 0 -> {"recovering", "NODE RECOVERING"}
      match?({:rolling, _, _}, rollout(projection)) -> {"rolling", "ROLLOUT IN PROGRESS"}
      projection["status"] == "ok" -> {"ok", "ALL SYSTEMS LIVE"}
      true -> {"degraded", "DEGRADED"}
    end
  end

  # A bounded, content-free event feed: only transitions this page itself
  # observed (no stored history), newest first.
  defp track_transitions(socket, projection) do
    previous = socket.assigns[:projection]

    cond do
      is_nil(previous) ->
        socket

      previous["cluster"] != projection["cluster"] ->
        %{"beam" => beam, "raft" => raft, "quorum" => quorum} = projection["cluster"]
        push_event_line(socket, "cluster now beam=#{beam} raft=#{raft} quorum=#{quorum}")

      revisions(previous) != revisions(projection) ->
        push_event_line(socket, "a node changed version (deploy in progress)")

      true ->
        socket
    end
  end

  defp revisions(projection),
    do: Enum.map(projection["nodes"], &{&1["release"], &1["revision"], &1["reachable"]})

  defp push_event_line(socket, line) do
    stamp = DateTime.utc_now() |> Calendar.strftime("%H:%M:%S UTC")
    events = Enum.take([{stamp, line} | socket.assigns.events], @events_kept)
    assign(socket, :events, events)
  end

  defp uptime_text(nil), do: "—"

  defp uptime_text(seconds) when is_integer(seconds) do
    cond do
      seconds >= 86_400 -> "#{div(seconds, 86_400)}d #{div(rem(seconds, 86_400), 3_600)}h"
      seconds >= 3_600 -> "#{div(seconds, 3_600)}h #{div(rem(seconds, 3_600), 60)}m"
      true -> "#{div(seconds, 60)}m"
    end
  end

  defp node_state(%{"reachable" => false}), do: "offline"
  defp node_state(_node), do: "connected"

  # ── forge pipeline (#126) ────────────────────────────────────────────────

  @pipeline_steps ~w(promoted building built deploying live)

  # Where the target sits in the status machine: {:progress, index} while
  # advancing, {:halted, status} on a terminal non-live state (failed,
  # reverted, needs_rolling_replace) — a revert is a story worth showing,
  # not hiding.
  defp pipeline(status) do
    case Enum.find_index(@pipeline_steps, &(&1 == status)) do
      nil -> {:halted, status}
      index -> {:progress, index}
    end
  end

  defp pipeline_steps, do: @pipeline_steps

  defp step_class(step_index, {:progress, at}) do
    cond do
      step_index < at ->
        "status-pipeline__step status-pipeline__step--done"

      step_index == at and at == length(@pipeline_steps) - 1 ->
        "status-pipeline__step status-pipeline__step--done"

      step_index == at ->
        "status-pipeline__step status-pipeline__step--active"

      true ->
        "status-pipeline__step"
    end
  end

  defp step_class(_step_index, {:halted, _status}), do: "status-pipeline__step"

  defp short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 12)
  defp short_sha(_sha), do: "—"

  defp plural([_one]), do: ""
  defp plural(_modules), do: "s"

  defp ms_text(ms) when is_integer(ms), do: "#{Float.round(ms / 1000, 1)}s"
  defp ms_text(_ms), do: "—"

  defp mirror_text(%{"state" => "current"}), do: "current"

  defp mirror_text(%{"state" => "lagging", "lagging_minutes" => minutes}),
    do: "lagging #{minutes}m"

  defp mirror_text(_state), do: "off"

  # The independence disclosure renders from whatever the projection carries, so
  # a node that could not assemble it shows nothing rather than an assertion it
  # cannot support.
  defp independence_badge(%{"degraded" => true}), do: :warning
  defp independence_badge(_disclosure), do: :success

  defp independence_text(%{"degraded" => true}), do: "degraded"
  defp independence_text(_disclosure), do: "no disclosed gap"

  # A proven invariant that is not deployed reports nothing, so the distance
  # between the two is worth a line of its own (#246). It says what it cannot
  # tell you as plainly as what it can.
  defp deployment_text(%{"known" => true, "behind" => 0, "proven_ref" => ref}),
    do: "running the head of #{ref}"

  defp deployment_text(%{"known" => true, "behind" => behind, "proven_ref" => ref})
       when is_integer(behind),
       do:
         "#{behind} #{if behind == 1, do: "commit", else: "commits"} behind #{ref}; " <>
           "an invariant proven there is not proven here"

  defp deployment_text(%{"proven_ref" => ref}),
    do: "distance from #{ref} unknown; this node serves no head it can measure against"

  defp gap_text(%{"family" => family, "status" => status, "issue" => issue})
       when is_integer(issue),
       do: "#{family} · #{status} · ##{issue}"

  defp gap_text(%{"family" => family, "status" => status}), do: "#{family} · #{status}"

  defp issue_text(issue) when is_integer(issue), do: " · ##{issue}"
  defp issue_text(_issue), do: ""

  defp deploy_result_variant("live"), do: :success
  defp deploy_result_variant("reverted"), do: :warning
  defp deploy_result_variant("needs_rolling_replace"), do: :warning
  defp deploy_result_variant(_result), do: :danger

  # Classification-only receipts ran no deployment; a receipt without a
  # recorded lane (rows older than the column) renders an honest "unknown".
  defp deploy_type_text(%{"result" => "needs_rolling_replace"}), do: "classification only"
  defp deploy_type_text(%{"type" => "direct_load"}), do: "direct load"
  defp deploy_type_text(%{"type" => "relup"}), do: "relup"
  defp deploy_type_text(%{"type" => "rolling_replacement"}), do: "rolling replacement"
  defp deploy_type_text(_deploy), do: "unknown lane"

  defp completed_text(iso) when is_binary(iso) do
    case DateTime.from_iso8601(iso) do
      {:ok, at, _offset} -> Calendar.strftime(at, "%Y-%m-%d %H:%M:%S UTC")
      _error -> "—"
    end
  end

  defp completed_text(_iso), do: "—"

  defp overall_badge_variant("ok"), do: :success
  defp overall_badge_variant("rolling"), do: :info
  defp overall_badge_variant("recovering"), do: :warning
  defp overall_badge_variant(_state), do: :error

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Status"
    >
      <main id="network-status-page" class="app-shell status-shell">
        <section class="status" aria-label="Network status">
          <header class="status-heading">
            <div>
              <h1>Network status</h1>
              <p>
                The OpenAgents fleet — a quorum cluster that survives node loss and
                upgrades in place. Owned sessions dropped per node loss or
                upgrade: 0; the release gate refuses anything that breaks that.
              </p>
            </div>
            <.badge id="status-overall" variant={overall_badge_variant(elem(@overall, 0))}>
              <.status_indicator state={elem(@overall, 0)} label={elem(@overall, 1)} decorative />
              {elem(@overall, 1)}
            </.badge>
          </header>

          <.card id="status-cluster">
            <div class="status-metrics">
              <div class="status-metric">
                <span class="status-metric__value">{@projection["cluster"]["beam"]}</span>
                <span class="status-metric__label">BEAM nodes</span>
              </div>
              <div class="status-metric">
                <span class="status-metric__value">{@projection["cluster"]["raft"]}</span>
                <span class="status-metric__label">Raft members</span>
              </div>
              <div class="status-metric">
                <span class="status-metric__value">
                  {if @projection["cluster"]["quorum"], do: "held", else: "lost"}
                </span>
                <span class="status-metric__label">quorum</span>
              </div>
              <div class="status-metric">
                <span class="status-metric__value">
                  {@projection["counts"]["machines_connected"] || "—"}
                </span>
                <span class="status-metric__label">computers connected</span>
              </div>
              <div class="status-metric">
                <span class="status-metric__value">{@projection["counts"]["active_jobs"] || "—"}</span>
                <span class="status-metric__label">active jobs</span>
              </div>
            </div>
          </.card>

          <.alert
            :if={match?({:rolling, _, _}, rollout(@projection))}
            id="status-rollout"
            variant={:info}
            title="Rollout in progress"
          >
            <% {:rolling, done, total} = rollout(@projection) %> Nodes are moving to a new version one at a time — {done}/{total} on the
            newest build. The cluster never drops below quorum during a roll.
          </.alert>

          <.card id="status-scvs">
            <h2>Active SCVs</h2>
            <p class="status-forge__intro">
              Follow the bounded public activity from SCVs that are running now.
              Prompts, repository paths, command arguments, tool output, and reports
              stay private.
            </p>

            <.empty :if={@scvs == []} id="status-no-scvs" title="No active SCVs">
              The next admitted SCV run will appear here as it works.
            </.empty>

            <Graph.scv_streams
              :if={@scvs != []}
              id="public-scv-streams"
              scvs={@scvs}
            />
          </.card>

          <div id="status-nodes" class="status-nodes">
            <.card
              :for={{node, index} <- Enum.with_index(@projection["nodes"])}
              id={"status-node-#{index}"}
            >
              <div class="status-node">
                <div class="status-node__head">
                  <.status_indicator
                    state={node_state(node)}
                    label={if node["reachable"], do: "reachable", else: "unreachable"}
                  />
                  <strong>{node["label"]}</strong>
                </div>
                <dl :if={node["reachable"]} class="status-node__facts">
                  <div>
                    <dt>release</dt><dd>{node["release"] || "—"}</dd>
                  </div>
                  <div>
                    <dt>revision</dt><dd>{node["revision"] || "—"}</dd>
                  </div>
                  <div :if={node["hot_loaded_at"]}>
                    <dt>hot-loaded</dt><dd>{node["hot_loaded_at"]}</dd>
                  </div>
                  <div>
                    <dt>uptime</dt><dd>{uptime_text(node["uptime_seconds"])}</dd>
                  </div>
                  <div>
                    <dt>sees</dt><dd>beam={node["beam_seen"]} raft={node["raft_seen"]}</dd>
                  </div>
                </dl>
                <p :if={!node["reachable"]} class="status-node__facts">
                  Not answering — rebooting or being replaced. The survivors hold
                  quorum and its work relocates to them.
                </p>
              </div>
            </.card>
          </div>

          <.card id="status-forge">
            <div class="status-node__head">
              <h2>Rapid deploys</h2>
              <.badge
                id="status-forge-state"
                variant={if @projection["forge"]["state"] == "active", do: :success, else: :warning}
              >
                {@projection["forge"]["state"]}
              </.badge>
            </div>
            <p class="status-forge__intro">
              Code moves through the OpenAgents forge: a push is promoted by an
              operator, built into just the changed modules, and hot-loaded
              across the fleet without a restart — watch it sweep the nodes
              above.
            </p>

            <.empty
              :if={is_nil(@projection["forge"]["target"])}
              id="status-forge-empty"
              title="No deploys yet"
            >
              The next promoted commit will appear here as it rolls out.
            </.empty>

            <div :if={target = @projection["forge"]["target"]} class="status-forge__target">
              <% position = pipeline(target["status"]) %>
              <ol class="status-pipeline" aria-label="Deploy pipeline">
                <li
                  :for={{step, index} <- Enum.with_index(pipeline_steps())}
                  class={step_class(index, position)}
                >
                  {step}
                </li>
              </ol>

              <div class="status-forge__facts">
                <span><code>{target["sha"]}</code></span>
                <.badge
                  :if={match?({:halted, _}, position)}
                  id="status-forge-halted"
                  variant={deploy_result_variant(target["status"])}
                >
                  {target["status"]}
                </.badge>
                <span>promoted by {target["promoted_by"] || "—"}</span>
                <span :if={target["modules"] > 0}>
                  {target["modules"]} module{if target["modules"] == 1, do: "", else: "s"}
                </span>
              </div>

              <div class="status-metrics status-forge__loop">
                <div class="status-metric">
                  <span class="status-metric__value">
                    {ms_text(@projection["forge"]["loop"]["last_ms"])}
                  </span>
                  <span class="status-metric__label">last push→live</span>
                </div>
                <div class="status-metric">
                  <span class="status-metric__value">
                    {ms_text(@projection["forge"]["loop"]["median_ms"])}
                  </span>
                  <span class="status-metric__label">median push→live</span>
                </div>
              </div>
            </div>

            <p class="status-forge__intro" id="status-forge-mirror">
              mirror: {mirror_text(@projection["forge"]["mirror"])}
            </p>

            <p
              class="status-forge__intro"
              id="status-forge-policy"
              data-path-order="direct,relup,rolling"
            >
              deploy policy: direct hot load → relup → rolling replacement
            </p>

            <div id="status-forge-history" class="status-forge__history">
              <h3>Recent deployments</h3>
              <.empty
                :if={@projection["forge"]["recent_deploys"] == []}
                id="status-forge-no-deploys"
                title="No deployment receipts yet"
              >
                Deployment receipts appear here newest first as Forge records them.
              </.empty>
              <ul :if={@projection["forge"]["recent_deploys"] != []} class="status-events">
                <li
                  :for={deploy <- @projection["forge"]["recent_deploys"]}
                  data-deploy-type={deploy["type"] || "none"}
                >
                  <span class="status-events__stamp"><code>{deploy["sha"]}</code></span>
                  <.badge variant={deploy_result_variant(deploy["result"])}>
                    {deploy["result"]}
                  </.badge>
                  {deploy_type_text(deploy)} · {deploy["modules"]} module{if deploy["modules"] == 1,
                    do: "",
                    else: "s"} · completed {completed_text(deploy["completed_at"])} · took {ms_text(
                    deploy["duration_ms"]
                  )}
                </li>
              </ul>
            </div>
          </.card>

          <.card :if={@projection["independence"]} id="status-independence">
            <h2>Operator independence</h2>
            <p class="status-forge__intro" id="status-independence-summary">
              <.badge variant={independence_badge(@projection["independence"])}>
                {independence_text(@projection["independence"])}
              </.badge>
              One operator accepts every push, promotes every deployment, issues every
              credential, and can read every record. What is provable about that is
              recorded in <code>{@projection["independence"]["document"]}</code>; what is
              not is listed here.
            </p>

            <ul class="status-events" id="status-independence-claims">
              <li id="status-independence-export">
                export: {@projection["independence"]["export"]["portable"]} of {@projection[
                  "independence"
                ]["export"]["families"]} families portable
              </li>
              <li id="status-independence-verification">
                verification: {@projection["independence"]["verification"]["property"]}
                <span :if={not @projection["independence"]["verification"]["anchor_published"]}>
                  · no anchor published{issue_text(
                    @projection["independence"]["verification"]["issue"]
                  )}
                </span>
                <span :if={@projection["independence"]["verification"]["anchor_published"]}>
                  · anchor at
                  <.link href={@projection["independence"]["verification"]["anchor"]}>
                    <code>{@projection["independence"]["verification"]["anchor"]}</code>
                  </.link>
                </span>
                <span
                  :if={
                    @projection["independence"]["verification"]["anchor_published"] and
                      not @projection["independence"]["verification"]["anchor_witnessed"]
                  }
                  id="status-independence-anchor-witness"
                >
                  · published by the operator, witnessed by nobody{issue_text(
                    @projection["independence"]["verification"]["issue"]
                  )}
                </span>
              </li>
              <li id="status-independence-private-data">
                private data: access controlled, not encrypted{issue_text(
                  @projection["independence"]["private_data"]["issue"]
                )}
              </li>
              <li :if={@projection["independence"]["deployment"]} id="status-independence-deployment">
                deployment: {deployment_text(@projection["independence"]["deployment"])}
              </li>
            </ul>

            <ul
              :if={@projection["independence"]["export"]["gaps"] != []}
              class="status-events"
              id="status-independence-gaps"
            >
              <li :for={gap <- @projection["independence"]["export"]["gaps"]}>
                {gap_text(gap)}
              </li>
            </ul>
          </.card>

          <.card id="status-events">
            <h2>Live events</h2>
            <.empty :if={@events == []} id="status-no-events" title="Quiet">
              No transitions observed since you opened this page.
            </.empty>
            <ul :if={@events != []} class="status-events">
              <li :for={{stamp, line} <- @events}>
                <span class="status-events__stamp">{stamp}</span> {line}
              </li>
            </ul>
          </.card>

          <footer class="status-footer">
            <p>
              Machine-readable: <code>GET /api/status</code>
              (schema <code>openagents.network_status.v1</code>) ·
              probe: <code>GET /health</code>. This page updates live.
            </p>
          </footer>
        </section>
      </main>
    </Layouts.app>
    """
  end
end