From 8e7116cb5ca8f3a19eb77c137c1dfad529acaa4d Mon Sep 17 00:00:00 2001 From: Khoi Ngo <67892332+BillQK@users.noreply.github.com> Date: Wed, 18 Jun 2025 22:33:19 -0400 Subject: [PATCH] feat: interactive demo --- .../infrastructure/workspace/manager.ex | 1 - apps/gitlock_phx/assets/css/landing.css | 14 + .../controllers/landing_html.ex | 48 +- .../controllers/landing_html/index.html.heex | 3 + .../live/hotspots_preview_live.ex | 691 ++++++++++++++++++ 5 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 apps/gitlock_phx/lib/gitlock_phx_web/live/hotspots_preview_live.ex diff --git a/apps/gitlock_core/lib/gitlock_core/infrastructure/workspace/manager.ex b/apps/gitlock_core/lib/gitlock_core/infrastructure/workspace/manager.ex index 2f4790c..fc6227c 100644 --- a/apps/gitlock_core/lib/gitlock_core/infrastructure/workspace/manager.ex +++ b/apps/gitlock_core/lib/gitlock_core/infrastructure/workspace/manager.ex @@ -358,7 +358,6 @@ defmodule GitlockCore.Infrastructure.Workspace.Manager do ["clone"] ++ Enum.flat_map(opts, fn {:depth, n} when is_integer(n) -> ["--depth", to_string(n)] - {:branch, b} when is_binary(b) -> ["--branch", b] {:single_branch, true} -> ["--single-branch"] _ -> [] end) diff --git a/apps/gitlock_phx/assets/css/landing.css b/apps/gitlock_phx/assets/css/landing.css index 20702f0..115b697 100644 --- a/apps/gitlock_phx/assets/css/landing.css +++ b/apps/gitlock_phx/assets/css/landing.css @@ -495,3 +495,17 @@ body { fill: white; } } +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 0.5s ease-out; +} diff --git a/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html.ex b/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html.ex index f35d393..cc5358c 100644 --- a/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html.ex +++ b/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html.ex @@ -222,7 +222,7 @@ defmodule GitlockPhxWeb.LandingHTML do BETA -
+
Terminal Friendly
@@ -1032,4 +1032,50 @@ defmodule GitlockPhxWeb.LandingHTML do """ end + + # Add this function to your LandingHTML module + def hotspots_demo_section(assigns) do + ~H""" +
+
+
+
+ + Live + +
+ + See It In Action + +
+
+

+ Try Hotspots Analysis Now +

+

+ Analyze any GitHub repository and see instant insights +

+
+ + +
+ <%= if @conn do %> + {live_render(@conn, GitlockPhxWeb.HotspotsPreviewLive, + id: "hotspots-preview", + container: {:div, class: "w-full"}, + sticky: true + )} + <% else %> + + + <% end %> +
+
+
+ """ + end end diff --git a/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html/index.html.heex b/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html/index.html.heex index 33cc971..09338da 100644 --- a/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html/index.html.heex +++ b/apps/gitlock_phx/lib/gitlock_phx_web/controllers/landing_html/index.html.heex @@ -11,6 +11,9 @@ <.features_section /> + + <.hotspots_demo_section conn={@conn} /> + <.stats_section /> diff --git a/apps/gitlock_phx/lib/gitlock_phx_web/live/hotspots_preview_live.ex b/apps/gitlock_phx/lib/gitlock_phx_web/live/hotspots_preview_live.ex new file mode 100644 index 0000000..bdd36e6 --- /dev/null +++ b/apps/gitlock_phx/lib/gitlock_phx_web/live/hotspots_preview_live.ex @@ -0,0 +1,691 @@ +defmodule GitlockPhxWeb.HotspotsPreviewLive do + use GitlockPhxWeb, :live_view + + @filtered_repos [ + %{ + name: "facebook/react", + url: "https://github.com/facebook/react", + description: "The library for web and native user interfaces", + stars: 228_000, + language: "JavaScript", + platform: "github" + }, + %{ + name: "microsoft/vscode", + url: "https://github.com/microsoft/vscode", + description: "Visual Studio Code", + stars: 163_000, + language: "TypeScript", + platform: "github" + }, + %{ + name: "vercel/next.js", + url: "https://github.com/vercel/next.js", + description: "The React Framework", + stars: 125_000, + language: "JavaScript", + platform: "github" + }, + %{ + name: "nodejs/node", + url: "https://github.com/nodejs/node", + description: "Node.js JavaScript runtime", + stars: 107_000, + language: "JavaScript", + platform: "github" + } + ] + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:github_url, "") + |> assign(:loading, false) + |> assign(:results, nil) + |> assign(:error, nil) + |> assign(:show_suggestions, false) + |> assign(:filtered_repos, @filtered_repos)} + end + + @impl true + def handle_event("update_url", %{"url" => url}, socket) do + {:noreply, assign(socket, :github_url, url)} + end + + @impl true + def handle_event("analyze", _params, socket) do + url = socket.assigns.github_url + + case validate_github_url(url) do + :ok -> + {:noreply, + socket + |> assign(:loading, true) + |> assign(:error, nil) + |> start_async(:analyze_repo, fn -> analyze_repository(url) end)} + + {:error, message} -> + {:noreply, assign(socket, :error, message)} + end + end + + @impl true + def handle_async(:analyze_repo, {:ok, results}, socket) do + {:noreply, + socket + |> assign(:loading, false) + |> assign(:results, results)} + end + + @impl true + def handle_async(:analyze_repo, {:exit, reason}, socket) do + error_message = + case reason do + {:error, msg} when is_binary(msg) -> msg + {:badarg, _} -> "Failed to parse analysis results" + _ -> "Analysis failed. Please try again." + end + + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, error_message)} + end + + defp analyze_repository(url) do + limit = 100_000 + + options = %{ + url: url, + format: "csv", + limit: limit, + min_revs: 1, + branch: "main" + } + + case GitlockCore.investigate(:hotspots, url, options) do + {:ok, csv_results} -> + parse_csv_results(url, csv_results, limit) + + {:error, reason} -> + raise format_error(reason) + end + end + + defp parse_csv_results(url, csv_string, limit) do + repo_name = url |> String.split("/") |> Enum.take(-2) |> Enum.join("/") + + try do + lines = String.split(csv_string, "\n", trim: true) + + case lines do + [header | data_lines] when length(data_lines) > 0 -> + headers = String.split(header, ",") + + # Parse CSV data into hotspot maps + hotspots = + data_lines + |> Enum.map(fn line -> parse_hotspot_line(line, headers) end) + |> Enum.filter(& &1) + # Limit for preview + |> Enum.take(limit) + + format_analysis_results(repo_name, hotspots) + + _ -> + # No data - return empty results + empty_results(repo_name) + end + rescue + e -> + IO.inspect(e, label: "CSV parsing error") + raise format_error(:csv_parse_error) + end + end + + defp parse_hotspot_line(line, headers) do + values = String.split(line, ",") + + if length(values) >= length(headers) do + # Create a map with header names as keys + data = Enum.zip(headers, values) |> Map.new() + + %{ + "entity" => Map.get(data, "entity", ""), + "revisions" => safe_to_integer(Map.get(data, "revisions", "0")), + "complexity" => safe_to_integer(Map.get(data, "complexity", "0")), + "loc" => safe_to_integer(Map.get(data, "loc", "0")), + "risk_score" => safe_to_float(Map.get(data, "risk_score", "0")), + "risk_factor" => Map.get(data, "risk_factor", "low") + } + else + nil + end + end + + defp safe_to_integer(value) when is_binary(value) do + case Integer.parse(value) do + {int, _} -> int + :error -> 0 + end + end + + defp safe_to_integer(value), do: 0 + + defp safe_to_float(value) when is_binary(value) do + case Float.parse(value) do + {float, _} -> float + :error -> 0.0 + end + end + + defp safe_to_float(value), do: 0.0 + + defp format_analysis_results(repo_name, hotspots) do + # Calculate accurate statistics from the hotspot data + total_files = length(hotspots) + + critical_files = + hotspots + |> Enum.count(fn h -> + risk = Map.get(h, "risk_factor", "low") + risk == "critical" || risk == :critical + end) + + high_risk_files = + hotspots + |> Enum.count(fn h -> + risk = Map.get(h, "risk_factor", "low") + risk == "high" || risk == :high + end) + + # Calculate average complexity properly + avg_complexity = + case hotspots do + [] -> + 0 + + _ -> + total_complexity = + hotspots + |> Enum.map(fn h -> Map.get(h, "complexity", 0) end) + |> Enum.sum() + + if total_complexity > 0 do + div(total_complexity, total_files) + else + 0 + end + end + + # Sum total revisions + total_revisions = + hotspots + |> Enum.map(fn h -> Map.get(h, "revisions", 0) end) + |> Enum.sum() + + # Calculate health score based on risk distribution + health_score = calculate_health_score(hotspots) + + # Format top hotspots for display - show top risk files + top_hotspots_formatted = + hotspots + |> Enum.sort_by( + fn h -> + {risk_to_number(Map.get(h, "risk_factor", "low")), Map.get(h, "risk_score", 0)} + end, + :desc + ) + |> Enum.take(4) + |> Enum.map(fn hotspot -> + %{ + file: Path.basename(Map.get(hotspot, "entity", "")), + risk: Map.get(hotspot, "risk_factor", "low"), + score: format_score(Map.get(hotspot, "risk_score", 0)), + changes: Map.get(hotspot, "revisions", 0) + } + end) + + %{ + repo_name: repo_name, + total_files: total_files, + critical_files: critical_files, + high_risk_files: high_risk_files, + hotspots: total_files, + health: health_score, + avg_complexity: avg_complexity, + total_revisions: total_revisions, + top_hotspots: top_hotspots_formatted + } + end + + defp risk_to_number("high"), do: 4 + defp risk_to_number("medium"), do: 3 + defp risk_to_number("low"), do: 2 + defp risk_to_number(_), do: 1 + + defp empty_results(repo_name) do + %{ + repo_name: repo_name, + total_files: 0, + critical_files: 0, + high_risk_files: 0, + hotspots: 0, + health: 100, + avg_complexity: 0, + total_revisions: 0, + top_hotspots: [] + } + end + + defp calculate_health_score(hotspots) do + case hotspots do + [] -> + 100 + + _ -> + # Weight different risk levels + risk_weights = %{ + "critical" => 10, + "high" => 5, + "medium" => 2, + "low" => 0.5 + } + + total_risk_weight = + hotspots + |> Enum.map(fn h -> + risk = Map.get(h, "risk_factor", "low") + Map.get(risk_weights, risk, 0.5) + end) + |> Enum.sum() + + # Normalize to 0-100 scale + max_possible_risk = length(hotspots) * 10 + risk_percentage = total_risk_weight / max_possible_risk * 100 + health = 100 - risk_percentage + + round(max(0, min(100, health))) + end + end + + defp format_score(score) when is_float(score) do + :erlang.float_to_binary(score, decimals: 1) + end + + defp format_score(score) when is_integer(score) do + "#{score}.0" + end + + defp format_score(score) when is_binary(score) do + case Float.parse(score) do + {float, _} -> format_score(float) + :error -> "0.0" + end + end + + @impl true + def handle_event("show_suggestions", _params, socket) do + {:noreply, assign(socket, :show_suggestions, true)} + end + + @impl true + def handle_event("hide_suggestions", _params, socket) do + # Delay hiding to allow click events to register + Process.send_after(self(), :hide_suggestions, 200) + {:noreply, socket} + end + + @impl true + def handle_event("select_repo", %{"url" => url}, socket) do + {:noreply, + socket + |> assign(:github_url, url) + |> assign(:show_suggestions, false)} + end + + @impl true + def handle_info(:hide_suggestions, socket) do + {:noreply, assign(socket, :show_suggestions, false)} + end + + # Helper functions: + + defp get_platform_icon("github") do + ~s""" + + + + """ + |> raw() + end + + defp get_platform_icon("gitlab") do + ~s""" + + + + """ + |> raw() + end + + defp get_platform_icon(_), do: get_platform_icon("github") + + defp get_language_color("JavaScript"), do: "bg-yellow-500" + defp get_language_color("TypeScript"), do: "bg-blue-500" + defp get_language_color("Python"), do: "bg-green-500" + defp get_language_color("Java"), do: "bg-orange-500" + defp get_language_color(_), do: "bg-gray-500" + + defp format_stars(stars) when stars >= 1000 do + "#{div(stars, 1000)}k" + end + + defp format_stars(stars), do: to_string(stars) + + defp format_score(_), do: "0.0" + + defp format_error(reason) when is_binary(reason), do: reason + defp format_error({:validation, msg}), do: msg + defp format_error({:io, _path, :enoent}), do: "Repository not found" + defp format_error(:csv_parse_error), do: "Failed to parse analysis results" + defp format_error(_), do: "Analysis failed. Please check the repository URL." + + defp validate_github_url(""), do: {:error, "Please enter a GitHub repository URL"} + + defp validate_github_url(url) do + if Regex.match?(~r/^https?:\/\/(www\.)?github\.com\/[\w-]+\/[\w.-]+\/?$/, url) do + :ok + else + {:error, "Please enter a valid GitHub repository URL"} + end + end + + defp risk_color("high"), do: "bg-error" + defp risk_color("medium"), do: "bg-warning" + defp risk_color(_), do: "bg-success" + + defp risk_badge_class("high"), do: "badge-error" + defp risk_badge_class("medium"), do: "badge-warning" + defp risk_badge_class(_), do: "badge-success" + + @impl true + def render(assigns) do + ~H""" +
+ +
+
+
+
+ +
+ +
+
+
+
+
+ + + +
+ + + + <%= if @show_suggestions do %> + + <% end %> +
+ +
+
+ + <%= if @error do %> +
+ + + + {@error} +
+ <% end %> +
+ + + <%= if @results do %> +
+ +
+
+
{@results.total_files}
+
Files Analyzed
+
+
+
+ {@results.critical_files + @results.high_risk_files} +
+
Risk Files
+
+
+
{@results.health}%
+
Health Score
+
+
+ + + <%= if length(@results.top_hotspots) > 0 do %> +
+
+

+ 🔥 Top Hotspots Found +

+ + Showing {length(@results.top_hotspots)} of {@results.hotspots} + +
+ +
+ <%= for hotspot <- @results.top_hotspots do %> +
+
+
+ {hotspot.file} +
+
+ {hotspot.changes} changes +
+ {hotspot.score} +
+
+
+ <% end %> +
+
+ <% else %> +
+

No significant hotspots found in this repository. Great job! 🎉

+
+ <% end %> + + +
+
+
Total Revisions
+
{@results.total_revisions}
+
+
+
Avg Complexity
+
{@results.avg_complexity}
+
+
+ + +
+ +
+
+ <% else %> + +
+ + + +

Try with any GitHub repository URL

+

Example: https://github.com/phoenixframework/phoenix

+
+ <% end %> +
+
+ """ + end +end