Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
310 lines
9.6 KiB
Elixir
310 lines
9.6 KiB
Elixir
defmodule TarakanWeb.RepositoryLive.Index do
|
|
use TarakanWeb, :live_view
|
|
|
|
alias Tarakan.Activity
|
|
alias Tarakan.AnalyticsCache
|
|
alias Tarakan.Community
|
|
alias Tarakan.FindingMemory
|
|
alias Tarakan.Fixes
|
|
alias Tarakan.Infestations
|
|
alias Tarakan.ModelAnalytics
|
|
alias Tarakan.Repositories
|
|
alias Tarakan.Scans
|
|
alias Tarakan.SecurityPosture
|
|
alias Tarakan.Work
|
|
alias TarakanWeb.Presence
|
|
|
|
@presence_topic "registry:observers"
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
if connected?(socket) do
|
|
Repositories.subscribe()
|
|
Activity.subscribe()
|
|
Community.subscribe()
|
|
Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)
|
|
|
|
{:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
|
|
end
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Public security record")
|
|
|> assign(
|
|
:meta_description,
|
|
"Local agents. Public Reports. Pick up Jobs or publish findings at a pinned commit."
|
|
)
|
|
|> assign(:canonical_path, ~p"/")
|
|
|> assign(:stats, Repositories.registry_stats())
|
|
|> assign(:contributor_count, Scans.public_contributor_count())
|
|
|> assign(:open_tasks, Work.list_open_public_tasks())
|
|
|> assign(:scan_queue, scan_queue())
|
|
|> assign(:infestations_window, 7)
|
|
|> assign(:infestations, home_infestations(7))
|
|
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))
|
|
|> assign(:observer_count, observer_count())
|
|
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))
|
|
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|
|
|> assign(:shout_form_version, 0)
|
|
|> assign(:can_moderate_shouts, Community.can_moderate?(socket.assigns.current_scope))
|
|
|> assign(:search_query, "")
|
|
|> assign(:search_results, [])
|
|
|> assign_record_effects()}
|
|
end
|
|
|
|
# Three aggregates over the whole record, identical for every visitor, on the
|
|
# busiest page there is. They go through the shared cache so a traffic spike
|
|
# cannot replay them per request.
|
|
defp assign_record_effects(socket) do
|
|
socket
|
|
|> assign_effect_lists()
|
|
|> assign_responsive_scale()
|
|
|> assign(
|
|
:pattern_graph,
|
|
AnalyticsCache.fetch({:home_pattern_graph, 12}, &encoded_pattern_graph/0,
|
|
on_unavailable: nil
|
|
)
|
|
)
|
|
end
|
|
|
|
# The slowest ranked median, used to scale the responsiveness bars.
|
|
defp assign_responsive_scale(socket) do
|
|
assign(
|
|
socket,
|
|
:responsive_max_days,
|
|
socket.assigns.responsive
|
|
|> Enum.map(& &1.median_days_to_fix)
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.max(fn -> 0 end)
|
|
)
|
|
end
|
|
|
|
# nil rather than an empty graph: a record with nothing entangled yet should
|
|
# render no field at all, not an empty one.
|
|
defp encoded_pattern_graph do
|
|
graph = Infestations.pattern_graph(patterns: 12)
|
|
|
|
if length(graph.nodes) >= 2, do: Jason.encode!(graph)
|
|
end
|
|
|
|
defp assign_effect_lists(socket) do
|
|
socket
|
|
|> assign(
|
|
:carried_fixes,
|
|
AnalyticsCache.fetch(
|
|
{:home_carried_fixes, 4},
|
|
fn ->
|
|
Fixes.list_recent_carried_fixes(limit: 4)
|
|
end,
|
|
on_unavailable: []
|
|
)
|
|
)
|
|
|> assign(
|
|
:regressions,
|
|
AnalyticsCache.fetch(
|
|
{:home_regressions, 4},
|
|
fn ->
|
|
FindingMemory.list_recent_regressions(limit: 4)
|
|
end,
|
|
on_unavailable: []
|
|
)
|
|
)
|
|
|> assign(
|
|
:responsive,
|
|
AnalyticsCache.fetch(
|
|
{:home_responsive, 5},
|
|
fn ->
|
|
SecurityPosture.leaderboard(limit: 5)
|
|
end,
|
|
on_unavailable: []
|
|
)
|
|
)
|
|
|> assign(
|
|
:model_precision,
|
|
AnalyticsCache.fetch(
|
|
{:home_model_precision, 5},
|
|
fn ->
|
|
# Ranked by precision rather than volume: the interesting question is
|
|
# which agent is right, not which one talks most.
|
|
ModelAnalytics.cached_model_scoreboard(limit: 25)
|
|
|> Enum.filter(& &1.precision)
|
|
|> Enum.sort_by(& &1.precision, :desc)
|
|
|> Enum.take(5)
|
|
end,
|
|
on_unavailable: []
|
|
)
|
|
)
|
|
end
|
|
|
|
# Bar width relative to the slowest ranked repository. A floor of 6% keeps the
|
|
# fastest row from rendering as an invisible sliver, and a zero maximum (every
|
|
# repo fixed same-day) fills the bar rather than dividing by zero.
|
|
defp home_bar_pct(_value, max) when max in [nil, 0, 0.0], do: 100
|
|
|
|
defp home_bar_pct(nil, _max), do: 0
|
|
|
|
defp home_bar_pct(value, max) do
|
|
value |> Kernel./(max) |> Kernel.*(100) |> Float.round(1) |> max(6.0) |> min(100.0)
|
|
end
|
|
|
|
@queue_limit 8
|
|
|
|
# list_shouts/2 serves "the most recent N", newest first. The shoutbox reads
|
|
# like a chat log - oldest visible message at the top, newest at the bottom.
|
|
defp chronological_shouts(scope) do
|
|
scope |> Community.list_shouts() |> Enum.reverse()
|
|
end
|
|
|
|
defp scan_queue do
|
|
Repositories.list_reviewable_repositories(
|
|
status: "unscanned",
|
|
limit: @queue_limit,
|
|
listing: :listed
|
|
)
|
|
end
|
|
|
|
@infestation_windows [7, 30, 90]
|
|
|
|
# Homepage floor is 3 repos: 2-repo matches are often unrelated findings that
|
|
# share a generic normalized title, so they would just add noise at scale.
|
|
# `list_infestations/1` ranks by the window's repo count (the pattern rollup
|
|
# keeps per-window counters), so the selected window drives the ranking.
|
|
@home_infestation_min_repos 3
|
|
|
|
defp home_infestations(days) when days in @infestation_windows do
|
|
Infestations.list_infestations(min_repos: @home_infestation_min_repos, days: days, limit: 12)
|
|
end
|
|
|
|
@infestations_debounce_ms 15_000
|
|
|
|
defp refresh_collective(socket) do
|
|
socket
|
|
|> assign(:contributor_count, Scans.public_contributor_count())
|
|
|> assign(:open_tasks, Work.list_open_public_tasks())
|
|
|> assign(:scan_queue, scan_queue())
|
|
|> maybe_refresh_infestations()
|
|
end
|
|
|
|
defp maybe_refresh_infestations(socket) do
|
|
now = System.monotonic_time(:millisecond)
|
|
last = Map.get(socket.assigns, :infestations_refreshed_at, 0)
|
|
|
|
if now - last >= @infestations_debounce_ms do
|
|
socket
|
|
|> assign(:infestations, home_infestations(socket.assigns.infestations_window))
|
|
|> assign(:infestations_refreshed_at, now)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:activity, entry}, socket) do
|
|
_entry = entry
|
|
{:noreply, refresh_collective(socket)}
|
|
end
|
|
|
|
def handle_info({:repository_registered, _repository}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stats, Repositories.registry_stats())
|
|
|> assign(:scan_queue, scan_queue())}
|
|
end
|
|
|
|
def handle_info({:repository_record_updated, repository}, socket) do
|
|
_repository = repository
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stats, Repositories.registry_stats())
|
|
|> refresh_collective()}
|
|
end
|
|
|
|
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
|
|
{:noreply, assign(socket, :observer_count, observer_count())}
|
|
end
|
|
|
|
def handle_info({event, _shout}, socket) when event in [:shout_posted, :shout_removed] do
|
|
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("infestations_window", %{"days" => days}, socket)
|
|
when days in ["7", "30", "90"] do
|
|
days = String.to_integer(days)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:infestations_window, days)
|
|
|> assign(:infestations, home_infestations(days))
|
|
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))}
|
|
end
|
|
|
|
def handle_event("search", params, socket) do
|
|
query = params |> Map.get("q", "") |> String.trim()
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search_query, query)
|
|
|> assign(:search_results, Repositories.search_repositories(query))}
|
|
end
|
|
|
|
def handle_event(
|
|
"post_shout",
|
|
%{"shout" => attrs},
|
|
%{assigns: %{current_scope: %{account: account}}} = socket
|
|
)
|
|
when not is_nil(account) do
|
|
case Community.create_shout(socket.assigns.current_scope, attrs) do
|
|
{:ok, _shout} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|
|
|> update(:shout_form_version, &(&1 + 1))
|
|
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))}
|
|
|
|
{:error, :rate_limited} ->
|
|
{:noreply, put_flash(socket, :error, "Slow down - a few shouts per minute is plenty.")}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:shout_form, to_form(changeset, as: :shout))
|
|
|> put_flash(:error, "Write a message before sending.")}
|
|
|
|
{:error, _reason} ->
|
|
{:noreply, put_flash(socket, :error, "That shout could not be posted.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("post_shout", _params, socket) do
|
|
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
|
end
|
|
|
|
def handle_event(
|
|
"remove_shout",
|
|
%{"id" => id},
|
|
%{assigns: %{current_scope: %{account: account}}} = socket
|
|
)
|
|
when not is_nil(account) do
|
|
with {:ok, shout} <- Community.get_shout(id),
|
|
{:ok, _removed} <-
|
|
Community.remove_shout(socket.assigns.current_scope, shout, %{
|
|
"removed_reason" => "community_moderation"
|
|
}) do
|
|
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
|
|
else
|
|
_error -> {:noreply, put_flash(socket, :error, "That shout could not be removed.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("remove_shout", _params, socket) do
|
|
{:noreply, put_flash(socket, :error, "That shout could not be removed.")}
|
|
end
|
|
|
|
defp observer_count do
|
|
@presence_topic |> Presence.list() |> map_size()
|
|
end
|
|
|
|
defp shout_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%b %-d · %H:%M")
|
|
end
|