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