defmodule ArcanaWeb.DashboardComponents do @moduledoc """ Shared components for the Arcana dashboard. """ use Phoenix.Component @doc """ Renders the dashboard layout with stats bar, navigation, and content. """ attr(:stats, :map, required: true) attr(:current_tab, :atom, required: true) slot(:inner_block, required: true) def dashboard_layout(assigns) do ~H"""
Arcana
<%= format_number(@stats.documents) %>
Documents
<%= format_number(@stats.chunks) %>
Chunks
<%= if @stats[:entities] do %>
<%= format_number(@stats.entities) %>
Entities
<%= format_number(@stats.relationships) %>
Relationships
<%= format_number(@stats.communities) %>
Communities
<% end %>
<%= render_slot(@inner_block) %>
""" end attr(:href, :string, required: true) attr(:active, :boolean, default: false) slot(:inner_block, required: true) defp nav_link(assigns) do ~H""" <%= render_slot(@inner_block) %> """ end # Helper functions shared across dashboard pages def parse_int(nil, default), do: default def parse_int("", default), do: default def parse_int(val, default) when is_binary(val) do case Integer.parse(val) do {num, _} -> num :error -> default end end def parse_float(nil, default), do: default def parse_float("", default), do: default def parse_float(val, default) when is_binary(val) do case Float.parse(val) do {num, _} -> num :error -> default end end def parse_mode("vector"), do: :vector def parse_mode("keyword"), do: :keyword def parse_mode("hybrid"), do: :hybrid # Deprecated form values: dashboards that bookmarked an old URL or # have a stale form still work, they just get normalized silently. def parse_mode("semantic"), do: :vector def parse_mode("fulltext"), do: :keyword def parse_mode(_), do: :vector def parse_format("plaintext"), do: :plaintext def parse_format("markdown"), do: :markdown def parse_format("elixir"), do: :elixir def parse_format(_), do: :plaintext def normalize_collection(""), do: "default" def normalize_collection(nil), do: "default" def normalize_collection(name) when is_binary(name), do: name def blank_to_nil(""), do: nil def blank_to_nil(nil), do: nil def blank_to_nil(value), do: value def format_metadata(nil), do: "-" def format_metadata(metadata) when metadata == %{}, do: "-" def format_metadata(metadata) when is_map(metadata) do Enum.map_join(metadata, ", ", fn {k, v} -> "#{k}: #{v}" end) end def format_pct(nil), do: "-" def format_pct(value) when is_float(value), do: "#{Float.round(value * 100, 1)}%" def format_pct(value) when is_integer(value), do: "#{value}%" def format_score(nil), do: "-" def format_score(value) when is_float(value), do: "#{Float.round(value, 1)}/10" def format_score(value) when is_integer(value), do: "#{value}/10" def format_number(n) when is_integer(n) do n |> Integer.to_string() |> String.replace(~r/\B(?=(\d{3})+(?!\d))/, ",") end def format_number(_), do: "-" def error_to_string(:too_large), do: "File too large (max 10MB)" def error_to_string(:too_many_files), do: "Too many files (max 10)" def error_to_string(:not_accepted), do: "File type not supported" def error_to_string(err), do: "Error: #{inspect(err)}" # Shared data loading functions def load_stats(repo) do import Ecto.Query doc_count = repo.aggregate(Arcana.Document, :count) chunk_count = repo.one(from(c in Arcana.Chunk, select: count(c.id))) || 0 base_stats = %{documents: doc_count, chunks: chunk_count} # Add graph stats if GraphRAG is available if Arcana.Graph.enabled?() do graph_stats = load_graph_stats(repo) Map.merge(base_stats, graph_stats) else base_stats end end defp load_graph_stats(repo) do import Ecto.Query entity_count = repo.one(from(e in Arcana.Graph.Entity, select: count(e.id))) || 0 relationship_count = repo.one(from(r in Arcana.Graph.Relationship, select: count(r.id))) || 0 community_count = repo.one(from(c in Arcana.Graph.Community, select: count(c.id))) || 0 %{entities: entity_count, relationships: relationship_count, communities: community_count} rescue # Tables might not exist if GraphRAG not installed _ -> %{} end def load_collections(repo) do import Ecto.Query repo.all( from(c in Arcana.Collection, left_join: d in Arcana.Document, on: d.collection_id == c.id, group_by: c.id, order_by: c.name, select: %{ id: c.id, name: c.name, description: c.description, document_count: count(d.id) } ) ) end def load_source_ids(repo) do import Ecto.Query repo.all( from(d in Arcana.Document, where: not is_nil(d.source_id), distinct: d.source_id, select: d.source_id ) ) end def get_repo_from_session(session) do session["repo"] || Application.get_env(:arcana, :repo) || raise "Missing :arcana, :repo config" end end