diff --git a/lib/lightning/channels.ex b/lib/lightning/channels.ex
index 7aeff4565b..4b3cb4d181 100644
--- a/lib/lightning/channels.ex
+++ b/lib/lightning/channels.ex
@@ -6,7 +6,12 @@ defmodule Lightning.Channels do
import Ecto.Query
+ alias Ecto.Multi
+ alias Lightning.Accounts.User
+ alias Lightning.Channels.Audit
alias Lightning.Channels.Channel
+ alias Lightning.Channels.ChannelAuthMethod
+ alias Lightning.Channels.ChannelRequest
alias Lightning.Channels.ChannelSnapshot
alias Lightning.Repo
@@ -21,6 +26,53 @@ defmodule Lightning.Channels do
|> Repo.all()
end
+ @doc """
+ Returns channels for a project with aggregate stats from channel_requests.
+
+ Each entry is a map with keys:
+ - all Channel fields (via struct)
+ - `:request_count` — total number of requests
+ - `:last_activity` — datetime of most recent request, or nil
+ """
+ def list_channels_for_project_with_stats(project_id) do
+ from(c in Channel,
+ where: c.project_id == ^project_id,
+ left_join: cr in ChannelRequest,
+ on: cr.channel_id == c.id,
+ group_by: c.id,
+ order_by: [asc: c.name],
+ select: %{
+ channel: c,
+ request_count: count(cr.id),
+ last_activity: max(cr.started_at)
+ }
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Returns aggregate stats for all channels in a project.
+
+ Returns a map with:
+ - `:total_channels` — number of channels in the project
+ - `:total_requests` — total channel requests across all channels
+
+ Uses a single query with a LEFT JOIN so both counts are fetched in one
+ database round-trip.
+ """
+ def get_channel_stats_for_project(project_id) do
+ from(c in Channel,
+ where: c.project_id == ^project_id,
+ left_join: cr in ChannelRequest,
+ on: cr.channel_id == c.id,
+ select: %{
+ total_channels: count(c.id, :distinct),
+ total_requests: count(cr.id)
+ }
+ )
+ |> Repo.one()
+ end
+
@doc """
Gets a single channel by ID. Returns nil if not found.
"""
@@ -55,26 +107,59 @@ defmodule Lightning.Channels do
@doc """
Gets a single channel. Raises if not found.
"""
- def get_channel!(id) do
- Repo.get!(Channel, id)
+ def get_channel!(id, opts \\ []) do
+ preloads = Keyword.get(opts, :include, [])
+ Repo.get!(Channel, id) |> Repo.preload(preloads)
+ end
+
+ @doc """
+ Gets a channel by ID scoped to a project. Returns `nil` if the channel
+ does not exist or belongs to a different project.
+ """
+ def get_channel_for_project(project_id, channel_id) do
+ Repo.get_by(Channel, id: channel_id, project_id: project_id)
end
@doc """
Creates a channel.
"""
- def create_channel(attrs) do
- %Channel{}
- |> Channel.changeset(attrs)
- |> Repo.insert()
+ @spec create_channel(map(), actor: User.t()) ::
+ {:ok, Channel.t()} | {:error, Ecto.Changeset.t()}
+ def create_channel(attrs, actor: %User{} = actor) do
+ changeset = Channel.changeset(%Channel{}, attrs)
+
+ Multi.new()
+ |> Multi.insert(:channel, changeset)
+ |> Multi.insert(:audit, fn %{channel: channel} ->
+ Audit.event("created", channel.id, actor, changeset)
+ end)
+ |> maybe_audit_auth_method_changes(changeset, actor)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{channel: channel}} -> {:ok, channel}
+ {:error, :channel, changeset, _} -> {:error, changeset}
+ end
end
@doc """
Updates a channel's config fields, bumping lock_version.
"""
- def update_channel(%Channel{} = channel, attrs) do
- channel
- |> Channel.changeset(attrs)
- |> Repo.update(stale_error_field: :lock_version)
+ @spec update_channel(Channel.t(), map(), actor: User.t()) ::
+ {:ok, Channel.t()} | {:error, Ecto.Changeset.t()}
+ def update_channel(%Channel{} = channel, attrs, actor: %User{} = actor) do
+ changeset = Channel.changeset(channel, attrs)
+
+ Multi.new()
+ |> Multi.update(:channel, changeset, stale_error_field: :lock_version)
+ |> Multi.insert(:audit, fn %{channel: updated} ->
+ Audit.event("updated", updated.id, actor, changeset)
+ end)
+ |> maybe_audit_auth_method_changes(changeset, actor)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{channel: channel}} -> {:ok, channel}
+ {:error, :channel, changeset, _} -> {:error, changeset}
+ end
end
@doc """
@@ -83,14 +168,107 @@ defmodule Lightning.Channels do
Returns `{:error, changeset}` if the channel has snapshots
(due to `:restrict` FK on `channel_snapshots`).
"""
- def delete_channel(%Channel{} = channel) do
- channel
- |> Ecto.Changeset.change()
- |> Ecto.Changeset.foreign_key_constraint(:channel_snapshots,
- name: "channel_snapshots_channel_id_fkey",
- message: "has history that must be retained"
+ @spec delete_channel(Channel.t(), actor: User.t()) ::
+ {:ok, Channel.t()} | {:error, Ecto.Changeset.t()}
+ def delete_channel(%Channel{} = channel, actor: %User{} = actor) do
+ changeset =
+ channel
+ |> Ecto.Changeset.change()
+ |> Ecto.Changeset.foreign_key_constraint(:channel_snapshots,
+ name: "channel_snapshots_channel_id_fkey",
+ message: "has history that must be retained"
+ )
+
+ Multi.new()
+ |> Multi.insert(:audit, Audit.event("deleted", channel.id, actor, %{}))
+ |> Multi.delete(:channel, changeset)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{channel: channel}} -> {:ok, channel}
+ {:error, :channel, changeset, _} -> {:error, changeset}
+ end
+ end
+
+ # Emits one "auth_method_added" or "auth_method_removed" audit step per
+ # association change. No-op when the changeset has no auth method changes
+ # (e.g. the toggle handler, which passes no "channel_auth_methods" key).
+ defp maybe_audit_auth_method_changes(multi, changeset, actor) do
+ auth_changes =
+ Ecto.Changeset.get_change(changeset, :channel_auth_methods, [])
+
+ inserted = Enum.filter(auth_changes, &(&1.action == :insert))
+ deleted = Enum.filter(auth_changes, &(&1.action == :delete))
+
+ multi
+ |> add_auth_method_added_audits(inserted, actor)
+ |> add_auth_method_removed_audits(deleted, actor)
+ end
+
+ defp add_auth_method_added_audits(multi, inserted, actor) do
+ inserted
+ |> Enum.with_index()
+ |> Enum.reduce(multi, fn {cs, idx}, acc ->
+ role = Ecto.Changeset.get_field(cs, :role)
+ fields = auth_method_fields_for(cs, role)
+
+ Multi.insert(
+ acc,
+ :"audit_auth_method_added_#{idx}",
+ fn %{channel: channel} ->
+ Audit.event("auth_method_added", channel.id, actor, %{
+ before: nil,
+ after: fields
+ })
+ end
+ )
+ end)
+ end
+
+ defp add_auth_method_removed_audits(multi, deleted, actor) do
+ deleted
+ |> Enum.with_index()
+ |> Enum.reduce(multi, fn {cs, idx}, acc ->
+ role = cs.data.role
+ fields = auth_method_fields_for(cs, role)
+
+ Multi.insert(
+ acc,
+ :"audit_auth_method_removed_#{idx}",
+ fn %{channel: channel} ->
+ Audit.event("auth_method_removed", channel.id, actor, %{
+ before: fields,
+ after: nil
+ })
+ end
+ )
+ end)
+ end
+
+ defp auth_method_fields_for(cs, :source) do
+ %{
+ role: "source",
+ webhook_auth_method_id:
+ Ecto.Changeset.get_field(cs, :webhook_auth_method_id)
+ }
+ end
+
+ defp auth_method_fields_for(cs, :sink) do
+ %{
+ role: "sink",
+ project_credential_id: Ecto.Changeset.get_field(cs, :project_credential_id)
+ }
+ end
+
+ @doc """
+ Returns all ChannelAuthMethod records for a channel, preloading
+ their associated webhook_auth_method and project_credential (with credential).
+ """
+ def list_channel_auth_methods(%Channel{} = channel) do
+ from(cam in ChannelAuthMethod,
+ where: cam.channel_id == ^channel.id,
+ preload: [:webhook_auth_method, project_credential: :credential]
)
- |> Repo.delete()
+ |> Repo.all()
end
@doc """
diff --git a/lib/lightning/channels/audit.ex b/lib/lightning/channels/audit.ex
new file mode 100644
index 0000000000..187463ea86
--- /dev/null
+++ b/lib/lightning/channels/audit.ex
@@ -0,0 +1,15 @@
+defmodule Lightning.Channels.Audit do
+ @moduledoc """
+ Audit trail for channel CRUD operations.
+ """
+ use Lightning.Auditing.Audit,
+ repo: Lightning.Repo,
+ item: "channel",
+ events: [
+ "created",
+ "updated",
+ "deleted",
+ "auth_method_added",
+ "auth_method_removed"
+ ]
+end
diff --git a/lib/lightning/channels/channel.ex b/lib/lightning/channels/channel.ex
index 7ec7b656e1..92b5f76cf1 100644
--- a/lib/lightning/channels/channel.ex
+++ b/lib/lightning/channels/channel.ex
@@ -54,5 +54,6 @@ defmodule Lightning.Channels.Channel do
|> assoc_constraint(:project)
|> unique_constraint([:project_id, :name])
|> optimistic_lock(:lock_version)
+ |> cast_assoc(:channel_auth_methods)
end
end
diff --git a/lib/lightning/channels/channel_auth_method.ex b/lib/lightning/channels/channel_auth_method.ex
index acb01d49dd..649805ecae 100644
--- a/lib/lightning/channels/channel_auth_method.ex
+++ b/lib/lightning/channels/channel_auth_method.ex
@@ -17,6 +17,7 @@ defmodule Lightning.Channels.ChannelAuthMethod do
schema "channel_auth_methods" do
field :role, Ecto.Enum, values: @roles
+ field :delete, :boolean, virtual: true
belongs_to :channel, Channel
belongs_to :webhook_auth_method, WebhookAuthMethod
@@ -29,11 +30,11 @@ defmodule Lightning.Channels.ChannelAuthMethod do
struct
|> cast(attrs, [
:role,
- :channel_id,
:webhook_auth_method_id,
- :project_credential_id
+ :project_credential_id,
+ :delete
])
- |> validate_required([:role, :channel_id])
+ |> validate_required([:role])
|> Validators.validate_exclusive(
[:webhook_auth_method_id, :project_credential_id],
"webhook_auth_method_id and project_credential_id are mutually exclusive"
@@ -52,6 +53,13 @@ defmodule Lightning.Channels.ChannelAuthMethod do
|> unique_constraint(:project_credential_id,
name: :channel_auth_methods_pc_unique
)
+ |> then(fn changeset ->
+ if get_change(changeset, :delete) do
+ %{changeset | action: :delete}
+ else
+ changeset
+ end
+ end)
end
defp validate_role_target_consistency(changeset) do
diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex
index 11df8cadbe..6d822a8177 100644
--- a/lib/lightning/config/bootstrap.ex
+++ b/lib/lightning/config/bootstrap.ex
@@ -53,6 +53,29 @@ defmodule Lightning.Config.Bootstrap do
if config_env() == :dev do
enabled = env!("LIVE_DEBUGGER", &Utils.ensure_boolean/1, true)
config :live_debugger, :disabled?, not enabled
+
+ live_debugger_ip =
+ env!(
+ "LIVE_DEBUGGER_IP",
+ fn address ->
+ address
+ |> String.split(".")
+ |> Enum.map(&String.to_integer/1)
+ |> List.to_tuple()
+ end,
+ nil
+ )
+
+ if live_debugger_ip do
+ config :live_debugger, :ip, live_debugger_ip
+ end
+
+ live_debugger_external_url =
+ env!("LIVE_DEBUGGER_EXTERNAL_URL", :string, nil)
+
+ if live_debugger_external_url do
+ config :live_debugger, :external_url, live_debugger_external_url
+ end
end
# Load storage and webhook retry config early so endpoint can respect it.
diff --git a/lib/lightning/policies/project_users.ex b/lib/lightning/policies/project_users.ex
index 507cc20120..647092700d 100644
--- a/lib/lightning/policies/project_users.ex
+++ b/lib/lightning/policies/project_users.ex
@@ -28,6 +28,9 @@ defmodule Lightning.Policies.ProjectUsers do
| :initiate_github_sync
| :create_collection
| :publish_template
+ | :create_channel
+ | :delete_channel
+ | :update_channel
@doc """
authorize/3 takes an action, a user, and a project. It checks the user's role
@@ -110,7 +113,10 @@ defmodule Lightning.Policies.ProjectUsers do
:delete_workflow,
:run_workflow,
:create_project_credential,
- :initiate_github_sync
+ :initiate_github_sync,
+ :create_channel,
+ :delete_channel,
+ :update_channel
]
def authorize(
diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex
index 86d57d498a..dea4ba789a 100644
--- a/lib/lightning/setup_utils.ex
+++ b/lib/lightning/setup_utils.ex
@@ -813,6 +813,9 @@ defmodule Lightning.SetupUtils do
Lightning.Projects.File,
Lightning.Projects.ProjectOauthClient,
Lightning.Credentials.OauthClient,
+ Lightning.Channels.ChannelRequest,
+ Lightning.Channels.ChannelSnapshot,
+ Lightning.Channels.Channel,
Lightning.Projects.Project,
Lightning.Collaboration.DocumentState
])
diff --git a/lib/lightning_web/live/channel_live/form_component.ex b/lib/lightning_web/live/channel_live/form_component.ex
new file mode 100644
index 0000000000..1639bb3d63
--- /dev/null
+++ b/lib/lightning_web/live/channel_live/form_component.ex
@@ -0,0 +1,276 @@
+defmodule LightningWeb.ChannelLive.FormComponent do
+ @moduledoc false
+ use LightningWeb, :live_component
+
+ alias Lightning.Channels
+ alias Lightning.Channels.Channel
+ alias Lightning.Projects
+ alias Lightning.WebhookAuthMethods
+
+ @impl true
+ def update(
+ %{channel: channel, project: project, on_close: _} = assigns,
+ socket
+ ) do
+ changeset = Channel.changeset(channel, %{})
+
+ wams = WebhookAuthMethods.list_for_project(project)
+ pcs = Projects.list_project_credentials(project)
+
+ current_source_ids =
+ channel.channel_auth_methods
+ |> Enum.filter(&(&1.role == :source))
+ |> Enum.map(& &1.webhook_auth_method_id)
+
+ current_sink_ids =
+ channel.channel_auth_methods
+ |> Enum.filter(&(&1.role == :sink))
+ |> Enum.map(& &1.project_credential_id)
+
+ source_selections =
+ Map.new(wams, fn wam -> {wam.id, wam.id in current_source_ids} end)
+
+ sink_selections =
+ Map.new(pcs, fn pc -> {pc.id, pc.id in current_sink_ids} end)
+
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign(
+ changeset: changeset,
+ webhook_auth_methods: wams,
+ project_credentials: pcs,
+ source_selections: source_selections,
+ sink_selections: sink_selections
+ )}
+ end
+
+ @impl true
+ def handle_event("validate", %{"channel" => params}, socket) do
+ changeset =
+ socket.assigns.channel
+ |> Channel.changeset(params)
+ |> Map.put(:action, :validate)
+
+ source_selections =
+ merge_selections(
+ socket.assigns.source_selections,
+ Map.get(params, "source_auth_methods", %{})
+ )
+
+ sink_selections =
+ merge_selections(
+ socket.assigns.sink_selections,
+ Map.get(params, "sink_auth_methods", %{})
+ )
+
+ {:noreply,
+ assign(socket,
+ changeset: changeset,
+ source_selections: source_selections,
+ sink_selections: sink_selections
+ )}
+ end
+
+ def handle_event("save", %{"channel" => params}, socket) do
+ save_channel(socket, socket.assigns.action, params)
+ end
+
+ @impl true
+ def render(assigns) do
+ assigns =
+ assign_new(assigns, :title, fn ->
+ case assigns.action do
+ :new -> "New Channel"
+ :edit -> "Edit Channel"
+ end
+ end)
+
+ ~H"""
+
+ <.modal show id={"#{@id}-modal"} width="w-full max-w-lg" on_close={@on_close}>
+ <:title>
+
+ {@title}
+
+
+
+
+ <.form
+ :let={f}
+ for={to_form(@changeset)}
+ id={"channel-form-#{if @action == :edit, do: @channel.id, else: "new"}"}
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ >
+
+ <.input field={f[:name]} label="Name" type="text" phx-debounce="300" />
+
+ <.input
+ field={f[:sink_url]}
+ label="Sink URL"
+ type="text"
+ phx-debounce="300"
+ />
+
+ <.input field={f[:enabled]} label="Enabled" type="toggle" />
+
+
+
+
+ Source Authentication Methods
+
+ <.link
+ navigate={~p"/projects/#{@project}/settings#webhook_security"}
+ class="text-xs link"
+ >
+ Create a new one in project settings.
+
+
+
+ <.input
+ :for={wam <- @webhook_auth_methods}
+ id={"source_auth_#{wam.id}"}
+ name={"#{f.name}[source_auth_methods][#{wam.id}]"}
+ type="checkbox"
+ value={Map.get(@source_selections, wam.id, false)}
+ label={wam.name}
+ />
+
+
+
+
+
+
+ Sink Credentials
+
+ <.link
+ navigate={~p"/projects/#{@project}/settings#credentials"}
+ class="text-xs link"
+ >
+ Create a new one in project settings.
+
+
+
+ <.input
+ :for={pc <- @project_credentials}
+ id={"sink_auth_#{pc.id}"}
+ name={"#{f.name}[sink_auth_methods][#{pc.id}]"}
+ type="checkbox"
+ value={Map.get(@sink_selections, pc.id, false)}
+ label={pc.credential.name}
+ />
+
+
+
+
+ <.modal_footer>
+ <.button type="submit" theme="primary" phx-target={@myself}>
+ Save
+
+ <.button theme="secondary" type="button" phx-click={@on_close}>
+ Cancel
+
+
+
+
+
+ """
+ end
+
+ defp save_channel(socket, :new, params) do
+ params =
+ Map.merge(params, %{
+ "project_id" => socket.assigns.project.id,
+ "channel_auth_methods" => build_auth_method_params(params, [])
+ })
+
+ case Channels.create_channel(params, actor: socket.assigns.current_user) do
+ {:ok, _channel} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Channel created successfully.")
+ |> push_patch(to: ~p"/projects/#{socket.assigns.project}/channels")}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+ end
+
+ defp save_channel(socket, :edit, params) do
+ params =
+ Map.put(
+ params,
+ "channel_auth_methods",
+ build_auth_method_params(
+ params,
+ socket.assigns.channel.channel_auth_methods
+ )
+ )
+
+ case Channels.update_channel(
+ socket.assigns.channel,
+ params,
+ actor: socket.assigns.current_user
+ ) do
+ {:ok, _channel} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Channel updated successfully.")
+ |> push_patch(to: ~p"/projects/#{socket.assigns.project}/channels")}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+ end
+
+ defp merge_selections(current, submitted) do
+ Map.new(current, fn {k, _v} ->
+ {k, Map.get(submitted, k, "false") == "true"}
+ end)
+ end
+
+ defp build_auth_method_params(params, current_auth_methods) do
+ build_auth_method_list(params, :source, current_auth_methods) ++
+ build_auth_method_list(params, :sink, current_auth_methods)
+ end
+
+ defp build_auth_method_list(params, role, current_auth_methods) do
+ id_field = auth_method_id_field(role)
+
+ params
+ |> Map.get(auth_method_param_key(role), %{})
+ |> Enum.reduce([], fn {k, v}, acc ->
+ existing_record =
+ Enum.find(current_auth_methods, &(Map.get(&1, id_field) == k))
+
+ case {existing_record, v} do
+ {%{}, "true"} -> [%{id: existing_record.id} | acc]
+ {nil, "true"} -> [%{id_field => k, role: to_string(role)} | acc]
+ {%{}, _} -> [%{id: existing_record.id, delete: true} | acc]
+ {nil, _} -> acc
+ end
+ end)
+ end
+
+ defp auth_method_id_field(:source), do: :webhook_auth_method_id
+ defp auth_method_id_field(:sink), do: :project_credential_id
+
+ defp auth_method_param_key(:source), do: "source_auth_methods"
+ defp auth_method_param_key(:sink), do: "sink_auth_methods"
+end
diff --git a/lib/lightning_web/live/channel_live/index.ex b/lib/lightning_web/live/channel_live/index.ex
new file mode 100644
index 0000000000..91f759417b
--- /dev/null
+++ b/lib/lightning_web/live/channel_live/index.ex
@@ -0,0 +1,363 @@
+defmodule LightningWeb.ChannelLive.Index do
+ @moduledoc false
+ use LightningWeb, :live_view
+
+ alias Lightning.Channels
+ alias Lightning.Policies.Permissions
+ alias Lightning.Policies.ProjectUsers
+ alias LightningWeb.ChannelLive.FormComponent
+ alias LightningWeb.Components.Common
+
+ on_mount {LightningWeb.Hooks, :project_scope}
+ on_mount {LightningWeb.Hooks, :check_limits}
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <:banner>
+
+
+ <:header>
+
+ <:breadcrumbs>
+
+
+
+ <:label>{@page_title}
+
+
+
+
+
+
+ <.channel_metrics channel_stats={@channel_stats} />
+
+
+
+ Channels
+ ({length(@channels)})
+
+ <.link
+ :if={@can_create_channel}
+ patch={~p"/projects/#{@project}/channels/new"}
+ >
+ <.button id="new-channel-button" theme="primary">
+ New Channel
+
+
+ <.button
+ :if={!@can_create_channel}
+ id="new-channel-button"
+ theme="primary"
+ disabled
+ tooltip="You are not authorized to perform this action."
+ >
+ New Channel
+
+
+ <.channels_table
+ id="channels-table"
+ channels={@channels}
+ can_edit_channel={@can_edit_channel}
+ can_delete_channel={@can_delete_channel}
+ project={@project}
+ />
+
+
+
+ <.live_component
+ :if={@live_action in [:new, :edit]}
+ module={FormComponent}
+ id={
+ (@selected_channel && @selected_channel.id &&
+ "edit-channel-#{@selected_channel.id}") || :new
+ }
+ action={@live_action}
+ channel={@selected_channel}
+ project={@project}
+ current_user={@current_user}
+ on_close={JS.patch(~p"/projects/#{@project}/channels")}
+ />
+ """
+ end
+
+ @impl true
+ def mount(_params, _session, socket) do
+ %{current_user: current_user, project: project} = socket.assigns
+
+ can_create_channel =
+ ProjectUsers
+ |> Permissions.can?(:create_channel, current_user, project)
+
+ can_edit_channel =
+ ProjectUsers
+ |> Permissions.can?(:update_channel, current_user, project)
+
+ can_delete_channel =
+ ProjectUsers
+ |> Permissions.can?(:delete_channel, current_user, project)
+
+ {:ok,
+ socket
+ |> assign(
+ active_menu_item: :channels,
+ can_create_channel: can_create_channel,
+ can_edit_channel: can_edit_channel,
+ can_delete_channel: can_delete_channel,
+ channels: [],
+ channel_stats: %{total_channels: 0, total_requests: 0}
+ )}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :index, _params) do
+ project_id = socket.assigns.project.id
+
+ socket
+ |> assign(
+ page_title: "Channels",
+ channels: Channels.list_channels_for_project_with_stats(project_id),
+ channel_stats: Channels.get_channel_stats_for_project(project_id),
+ selected_channel: nil
+ )
+ end
+
+ defp apply_action(socket, :new, _params) do
+ if socket.assigns.can_create_channel do
+ socket
+ |> assign(
+ page_title: "New Channel",
+ selected_channel: %Lightning.Channels.Channel{channel_auth_methods: []}
+ )
+ else
+ socket
+ |> put_flash(:error, "You are not authorized to create channels.")
+ |> push_navigate(to: ~p"/projects/#{socket.assigns.project.id}/channels")
+ end
+ end
+
+ defp apply_action(socket, :edit, %{"id" => id}) do
+ if socket.assigns.can_edit_channel do
+ channel = Channels.get_channel!(id, include: [:channel_auth_methods])
+
+ socket
+ |> assign(
+ page_title: "Edit Channel",
+ selected_channel: channel
+ )
+ else
+ socket
+ |> put_flash(:error, "You are not authorized to edit channels.")
+ |> push_navigate(to: ~p"/projects/#{socket.assigns.project.id}/channels")
+ end
+ end
+
+ @impl true
+ def handle_event(
+ "toggle_channel_state",
+ %{"channel_state" => enabled?, "value_key" => channel_id},
+ socket
+ ) do
+ with :ok <- check_can_edit_channel(socket),
+ {:ok, channel} <- fetch_project_channel(socket, channel_id) do
+ case Channels.update_channel(channel, %{enabled: enabled?},
+ actor: socket.assigns.current_user
+ ) do
+ {:ok, _channel} ->
+ socket
+ |> put_flash(:info, "Channel updated")
+ |> push_patch(to: ~p"/projects/#{socket.assigns.project.id}/channels")
+ |> noreply()
+
+ {:error, _changeset} ->
+ socket
+ |> put_flash(:error, "Failed to update channel. Please try again.")
+ |> noreply()
+ end
+ end
+ end
+
+ @impl true
+ def handle_event("delete_channel", %{"id" => id}, socket) do
+ with :ok <- check_can_delete_channel(socket),
+ {:ok, channel} <- fetch_project_channel(socket, id) do
+ case Channels.delete_channel(channel, actor: socket.assigns.current_user) do
+ {:ok, _} ->
+ socket
+ |> put_flash(:info, "Channel deleted.")
+ |> push_patch(to: ~p"/projects/#{socket.assigns.project.id}/channels")
+ |> noreply()
+
+ {:error, changeset} ->
+ message =
+ if Keyword.has_key?(changeset.errors, :channel_snapshots),
+ do:
+ "Cannot delete \"#{channel.name}\" — it has request history that must be retained.",
+ else: "Failed to delete channel. Please try again."
+
+ socket |> put_flash(:error, message) |> noreply()
+ end
+ end
+ end
+
+ defp check_can_edit_channel(socket) do
+ if socket.assigns.can_edit_channel do
+ :ok
+ else
+ socket
+ |> put_flash(:error, "You are not authorized to perform this action.")
+ |> noreply()
+ end
+ end
+
+ defp check_can_delete_channel(socket) do
+ if socket.assigns.can_delete_channel do
+ :ok
+ else
+ socket
+ |> put_flash(:error, "You are not authorized to perform this action.")
+ |> noreply()
+ end
+ end
+
+ defp fetch_project_channel(socket, channel_id) do
+ case Channels.get_channel_for_project(socket.assigns.project.id, channel_id) do
+ %{} = channel -> {:ok, channel}
+ nil -> socket |> put_flash(:error, "Channel not found.") |> noreply()
+ end
+ end
+
+ # --- Private components ---
+
+ attr :channel_stats, :map, required: true
+
+ defp channel_metrics(assigns) do
+ ~H"""
+
+
+
Total Channels
+
+ {@channel_stats.total_channels}
+
+
+
+
Total Requests
+
+ {@channel_stats.total_requests}
+
+
+
+ """
+ end
+
+ attr :id, :string, required: true
+ attr :channels, :list, required: true
+ attr :project, :map, required: true
+ attr :can_edit_channel, :boolean, default: false
+ attr :can_delete_channel, :boolean, default: false
+
+ defp channels_table(%{channels: []} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ defp channels_table(assigns) do
+ ~H"""
+ <.table id={@id}>
+ <:header>
+ <.tr>
+ <.th>Name
+ <.th>Sink URL
+ <.th>Requests
+ <.th>Last Activity
+ <.th>Enabled
+ <.th>Actions
+
+
+ <:body>
+ <%= for %{channel: channel, request_count: count, last_activity: last_at} <-
+ @channels do %>
+ <.tr id={"channel-#{channel.id}"}>
+ <.td class="wrap-break-word max-w-[15rem]">
+
+ {channel.name}
+
+
+ <.td class="wrap-break-word max-w-[20rem] text-sm text-gray-600 font-mono">
+ {channel.sink_url}
+
+ <.td class="text-gray-700">
+ {count}
+
+ <.td class="text-gray-500 text-sm">
+ <%= if last_at do %>
+
+ <% else %>
+ Never
+ <% end %>
+
+ <.td>
+ <%= if @can_edit_channel do %>
+ <.input
+ id={channel.id}
+ type="toggle"
+ name="channel_state"
+ value={channel.enabled}
+ tooltip={unless channel.enabled, do: "#{channel.name} (disabled)"}
+ on_click="toggle_channel_state"
+ value_key={channel.id}
+ />
+ <% else %>
+
+ {if channel.enabled, do: "Enabled", else: "Disabled"}
+
+ <% end %>
+
+ <.td class="text-right">
+
+ <.link
+ :if={@can_edit_channel}
+ patch={~p"/projects/#{@project}/channels/#{channel.id}/edit"}
+ >
+ <.button theme="secondary" size="sm">Edit
+
+ <.button
+ :if={@can_delete_channel}
+ theme="danger"
+ size="sm"
+ phx-click="delete_channel"
+ phx-value-id={channel.id}
+ data-confirm={"Delete \"#{channel.name}\"? This cannot be undone."}
+ >
+ Delete
+
+
+
+
+ <% end %>
+
+
+ """
+ end
+end
diff --git a/lib/lightning_web/live/components/icon.ex b/lib/lightning_web/live/components/icon.ex
index 5b5add0913..272fa32186 100644
--- a/lib/lightning_web/live/components/icon.ex
+++ b/lib/lightning_web/live/components/icon.ex
@@ -37,6 +37,8 @@ defmodule LightningWeb.Components.Icon do
def sandboxes(assigns), do: Heroicons.beaker(assigns)
+ def channels(assigns), do: Heroicons.arrows_right_left(assigns)
+
def branches(assigns) do
~H"""