Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View file

@ -0,0 +1,281 @@
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