Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
281 lines
10 KiB
Elixir
281 lines
10 KiB
Elixir
defmodule TarakanWeb.AlertLive do
|
|
@moduledoc """
|
|
Watchlist management for finding alerts.
|
|
|
|
Each watchlist is a named set of `owner/name` repository refs; the daily
|
|
`Tarakan.Billing.AlertWorker` emails the account when verified or fixed
|
|
findings first appear on those repositories (when `notify` is on).
|
|
"""
|
|
|
|
use TarakanWeb, :live_view
|
|
|
|
alias Tarakan.Billing
|
|
alias Tarakan.Billing.Watchlist
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
account = socket.assigns.current_scope.account
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Alerts")
|
|
|> assign(:editing_id, nil)
|
|
|> assign(:form, watchlist_form(%{}))
|
|
|> stream(:watchlists, Billing.list_watchlists(account),
|
|
dom_id: fn watchlist -> "watchlist-#{watchlist.id}" end
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"watchlist" => params}, socket) do
|
|
{:noreply, assign(socket, :form, watchlist_form(params))}
|
|
end
|
|
|
|
def handle_event("save", %{"watchlist" => params}, socket) do
|
|
attrs = %{"name" => params["name"], "entries" => parse_entries(params["entries"])}
|
|
|
|
case socket.assigns.editing_id do
|
|
nil -> create(socket, attrs)
|
|
id -> save_edit(socket, id, attrs)
|
|
end
|
|
end
|
|
|
|
def handle_event("edit", %{"id" => id}, socket) do
|
|
watchlist = find_watchlist(socket, id)
|
|
|
|
if watchlist do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:editing_id, watchlist.id)
|
|
|> assign(
|
|
:form,
|
|
watchlist_form(%{
|
|
"name" => watchlist.name,
|
|
"entries" => Enum.join(watchlist.entries, "\n")
|
|
})
|
|
)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_edit", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:editing_id, nil)
|
|
|> assign(:form, watchlist_form(%{}))}
|
|
end
|
|
|
|
def handle_event("delete", %{"id" => id}, socket) do
|
|
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
|
|
{:ok, _} <- Billing.delete_watchlist(socket.assigns.current_scope, watchlist) do
|
|
{:noreply,
|
|
socket
|
|
|> stream_delete(:watchlists, watchlist)
|
|
|> put_flash(:info, "Watchlist deleted.")}
|
|
else
|
|
_other -> {:noreply, put_flash(socket, :error, "Could not delete that watchlist.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("toggle_notify", %{"id" => id}, socket) do
|
|
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
|
|
{:ok, updated} <-
|
|
Billing.update_watchlist(socket.assigns.current_scope, watchlist, %{
|
|
"notify" => !watchlist.notify
|
|
}) do
|
|
{:noreply, stream_insert(socket, :watchlists, updated)}
|
|
else
|
|
_other -> {:noreply, put_flash(socket, :error, "Could not update that watchlist.")}
|
|
end
|
|
end
|
|
|
|
defp create(socket, attrs) do
|
|
case Billing.create_watchlist(socket.assigns.current_scope, attrs) do
|
|
{:ok, watchlist} ->
|
|
{:noreply,
|
|
socket
|
|
|> stream_insert(:watchlists, watchlist, at: 0)
|
|
|> assign(:form, watchlist_form(%{}))
|
|
|> put_flash(:info, "Watchlist created.")}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, put_flash(socket, :error, changeset_message(changeset))}
|
|
end
|
|
end
|
|
|
|
defp save_edit(socket, id, attrs) do
|
|
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
|
|
{:ok, updated} <-
|
|
Billing.update_watchlist(socket.assigns.current_scope, watchlist, attrs) do
|
|
{:noreply,
|
|
socket
|
|
|> stream_insert(:watchlists, updated)
|
|
|> assign(:editing_id, nil)
|
|
|> assign(:form, watchlist_form(%{}))
|
|
|> put_flash(:info, "Watchlist updated.")}
|
|
else
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, put_flash(socket, :error, changeset_message(changeset))}
|
|
|
|
_other ->
|
|
{:noreply, put_flash(socket, :error, "Could not update that watchlist.")}
|
|
end
|
|
end
|
|
|
|
# Watchlists are small and owner-scoped; re-reading the list keeps the
|
|
# stream and form lookups simple.
|
|
defp find_watchlist(socket, id) do
|
|
id = if is_binary(id), do: String.to_integer(id), else: id
|
|
account = socket.assigns.current_scope.account
|
|
|
|
Enum.find(Billing.list_watchlists(account), fn watchlist ->
|
|
watchlist.id == id
|
|
end)
|
|
end
|
|
|
|
defp parse_entries(nil), do: []
|
|
|
|
defp parse_entries(text) when is_binary(text) do
|
|
text
|
|
|> String.split("\n", trim: true)
|
|
|> Enum.map(&String.trim/1)
|
|
|> Enum.reject(&(&1 == ""))
|
|
end
|
|
|
|
defp changeset_message(changeset) do
|
|
details =
|
|
changeset
|
|
|> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end)
|
|
|> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
|
|
|
|
"The watchlist could not be saved: #{details}."
|
|
end
|
|
|
|
defp watchlist_form(params), do: to_form(params, as: :watchlist)
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
|
<Layouts.page width={:focused} class="space-y-8">
|
|
<header class="border-b-2 border-strong pb-6">
|
|
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
|
|
Alerts
|
|
</h1>
|
|
<p class="mt-3 text-sm leading-6 text-ink-muted">
|
|
Watch repositories and get a daily email when verified or fixed findings first
|
|
appear on them. Alert delivery requires an active plan (see <.link
|
|
navigate={~p"/pricing"}
|
|
class="text-ink underline"
|
|
>pricing</.link>).
|
|
</p>
|
|
</header>
|
|
|
|
<.form
|
|
for={@form}
|
|
id="watchlist-form"
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
class="space-y-5 border-2 border-strong p-5"
|
|
>
|
|
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
|
{if @editing_id, do: "Edit watchlist", else: "New watchlist"}
|
|
</h2>
|
|
<.input field={@form[:name]} type="text" label="Name" required maxlength="100" />
|
|
<.input
|
|
field={@form[:entries]}
|
|
type="textarea"
|
|
label="Repositories - one owner/name per line"
|
|
rows="5"
|
|
placeholder="openai/codex\nacme/widget"
|
|
/>
|
|
<div class="flex flex-wrap gap-3">
|
|
<button
|
|
id="watchlist-save-button"
|
|
type="submit"
|
|
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
|
>
|
|
{if @editing_id, do: "Save changes", else: "Create watchlist"}
|
|
</button>
|
|
<button
|
|
:if={@editing_id}
|
|
id="watchlist-cancel-button"
|
|
type="button"
|
|
phx-click="cancel_edit"
|
|
class="cursor-pointer border border-strong px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
|
|
<div id="watchlists" phx-update="stream" class="space-y-4">
|
|
<div
|
|
id="watchlists-empty"
|
|
class="hidden only:block border-2 border-dashed border-rule p-5 text-sm text-ink-faint"
|
|
>
|
|
No watchlists yet - create one above to start getting alerts.
|
|
</div>
|
|
<div
|
|
:for={{id, watchlist} <- @streams.watchlists}
|
|
id={id}
|
|
class="border-2 border-strong p-5"
|
|
>
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<h3 class="font-display text-base uppercase tracking-[0.02em] text-ink">
|
|
{watchlist.name}
|
|
</h3>
|
|
<p class="mt-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
|
{length(watchlist.entries)} repositories
|
|
<span :if={watchlist.last_notified_at}>
|
|
· last alerted {Calendar.strftime(watchlist.last_notified_at, "%Y-%m-%d")}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
id={"watchlist-notify-#{watchlist.id}"}
|
|
type="button"
|
|
phx-click="toggle_notify"
|
|
phx-value-id={watchlist.id}
|
|
class={[
|
|
"cursor-pointer border-2 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] transition",
|
|
watchlist.notify && "border-signal text-signal hover:bg-signal hover:text-ground",
|
|
!watchlist.notify && "border-strong text-ink-faint hover:bg-panel hover:text-ink"
|
|
]}
|
|
>
|
|
{if watchlist.notify, do: "Alerts on", else: "Alerts off"}
|
|
</button>
|
|
<button
|
|
id={"watchlist-edit-#{watchlist.id}"}
|
|
type="button"
|
|
phx-click="edit"
|
|
phx-value-id={watchlist.id}
|
|
class="cursor-pointer border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
id={"watchlist-delete-#{watchlist.id}"}
|
|
type="button"
|
|
phx-click="delete"
|
|
phx-value-id={watchlist.id}
|
|
data-confirm="Delete this watchlist?"
|
|
class="cursor-pointer border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-signal"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ul :if={watchlist.entries != []} class="mt-3 space-y-1 font-mono text-xs text-ink-muted">
|
|
<li :for={entry <- watchlist.entries}>{entry}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</Layouts.page>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|