diff --git a/config/dev.exs b/config/dev.exs
index 8297eee630..e04e4e4661 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -58,6 +58,9 @@ config :sanbase, Sanbase.KafkaExporter, producer: Sanbase.InMemoryKafka.Producer
# with. When running the app locally these values are overridden by the values
# in the .env.dev or dev.secret.exs files, which are ignored by git and not
# published in the repository. Please do not report these as security issues.
+# To create the user for your local env:
+# In psql: CREATE ROLE postgres WITH LOGIN SUPERUSER PASSWORD 'postgres';
+# In the terminal: mix ecto.setup
config :sanbase, Sanbase.Repo,
username: "postgres",
password: "postgres",
diff --git a/lib/mix/tasks/clone_metric_registry.ex b/lib/mix/tasks/clone_metric_registry.ex
new file mode 100644
index 0000000000..d0d20c6e1f
--- /dev/null
+++ b/lib/mix/tasks/clone_metric_registry.ex
@@ -0,0 +1,60 @@
+defmodule Mix.Tasks.CloneMetricRegistry do
+ @shortdoc "Make sure the destructive operations are not executed on production databases"
+
+ @moduledoc """
+ #{@shortdoc}
+
+ Check the MIX_ENV environment variable, the DATABASE_URL environment variable and
+ the database configuration to determine if the operation is executed in dev
+ or test environment against a production database.
+ """
+
+ use Mix.Task
+
+ @impl Mix.Task
+ def run(args) do
+ opts = parse_args(args)
+ file = Path.join([__DIR__, "metric_registry_pg_dump.sql"])
+ db_url = Keyword.get(opts, :database_url) |> String.replace_prefix("ecto://", "postgres://")
+
+ try do
+ {"", 0} =
+ System.cmd(
+ "pg_dump",
+ [
+ "--data-only",
+ "--table=metric_registry",
+ "--file=#{file}",
+ "--dbname=#{db_url}",
+ # So it can be used by pg_restore
+ # "--format=custom",
+ "--inserts"
+ ]
+ )
+
+ contents = File.read!(file)
+ contents = String.replace(contents, "sanbase2.metric_registry", "public.metric_registry")
+ File.write!(file, contents)
+
+ System.cmd(
+ "psql",
+ [
+ "--dbname=sanbase_dev",
+ "--file=#{file}",
+ "--echo-all"
+ ]
+ )
+ after
+ File.rm!(file)
+ end
+
+ :ok
+ end
+
+ defp parse_args(args) do
+ {options, _, _} =
+ OptionParser.parse(args, strict: [database_url: :string, drop_existing: :boolean])
+
+ options
+ end
+end
diff --git a/lib/sanbase/metric/registry/change_suggestion.ex b/lib/sanbase/metric/registry/change_suggestion.ex
index 0808999afd..9ac19898b4 100644
--- a/lib/sanbase/metric/registry/change_suggestion.ex
+++ b/lib/sanbase/metric/registry/change_suggestion.ex
@@ -138,10 +138,16 @@ defmodule Sanbase.Metric.Registry.ChangeSuggestion do
# only after the DB changes are commited and not from insite the transaction. If the event
# is emitted from inside the transaction, the event handler can be invoked before the DB
# changes are commited and this handler will have no effect.
- Sanbase.Metric.Registry.update(metric_registry, params, emit_event?: false)
+ Sanbase.Metric.Registry.update(metric_registry, params, emit_event: true)
end
def create_change_suggestion(%Registry{} = registry, params, notes, submitted_by) do
+ # After change suggestion is applied, put the metric in a unverified state and mark
+ # is as not synced. Someone needs to manually verify the metric after it is tested.
+ # When the data is synced between stage and prod, the sync status will be updated.
+ # Note: Keep the keys as strings, not atoms, so the map is not mixed
+ params = Map.merge(params, %{"is_verified" => false, "sync_status" => "not_synced"})
+
case Registry.changeset(registry, params) do
%{valid?: false} = changeset ->
{:error, changeset}
diff --git a/lib/sanbase/metric/registry/embedded_schemas.ex b/lib/sanbase/metric/registry/embedded_schemas.ex
new file mode 100644
index 0000000000..6622e4aa3a
--- /dev/null
+++ b/lib/sanbase/metric/registry/embedded_schemas.ex
@@ -0,0 +1,75 @@
+defmodule Sanbase.Metric.Registry.Selector do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @derive {Jason.Encoder, only: [:type]}
+
+ @primary_key false
+ embedded_schema do
+ field(:type, :string)
+ end
+
+ def changeset(%__MODULE__{} = struct, attrs) do
+ struct
+ |> cast(attrs, [:type])
+ |> validate_required([:type])
+ end
+end
+
+defmodule Sanbase.Metric.Registry.Table do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @derive {Jason.Encoder, only: [:name]}
+
+ @primary_key false
+ embedded_schema do
+ field(:name, :string)
+ end
+
+ def changeset(%__MODULE__{} = struct, attrs) do
+ struct
+ |> cast(attrs, [:name])
+ |> validate_required([:name])
+ |> validate_format(:name, ~r/[a-z0-9_\-]/)
+ end
+end
+
+defmodule Sanbase.Metric.Registry.Alias do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @derive {Jason.Encoder, only: [:name]}
+
+ @primary_key false
+ embedded_schema do
+ field(:name, :string)
+ end
+
+ def changeset(%__MODULE__{} = struct, attrs) do
+ struct
+ |> cast(attrs, [:name])
+ |> validate_required(:name)
+ |> validate_format(:name, Sanbase.Metric.Registry.metric_regex())
+ |> validate_length(:name, min: 3, max: 100)
+ end
+end
+
+defmodule Sanbase.Metric.Registry.Doc do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @derive {Jason.Encoder, only: [:link]}
+
+ @primary_key false
+ embedded_schema do
+ field(:link, :string)
+ end
+
+ def changeset(%__MODULE__{} = struct, attrs) do
+ struct
+ |> cast(attrs, [:link])
+ |> validate_required([:link])
+ |> validate_format(:link, ~r|https://academy.santiment.net|)
+ end
+end
diff --git a/lib/sanbase/metric/registry/registry.ex b/lib/sanbase/metric/registry/registry.ex
index 593304a9c7..348111fd56 100644
--- a/lib/sanbase/metric/registry/registry.ex
+++ b/lib/sanbase/metric/registry/registry.ex
@@ -5,9 +5,14 @@ defmodule Sanbase.Metric.Registry do
import Ecto.Changeset
import Sanbase.Metric.Registry.EventEmitter, only: [emit_event: 3]
- alias Sanbase.Repo
alias __MODULE__.Validation
alias __MODULE__.ChangeSuggestion
+ alias __MODULE__.Doc
+ alias __MODULE__.Alias
+ alias __MODULE__.Selector
+ alias __MODULE__.Table
+
+ alias Sanbase.Repo
alias Sanbase.TemplateEngine
# Matches letters, digits, _, -, :, ., {, }, (, ), \, / and space
@@ -18,74 +23,6 @@ defmodule Sanbase.Metric.Registry do
@metric_regex ~r/^[a-z0-9_{}:]+$/
def metric_regex(), do: @metric_regex
- defmodule Selector do
- use Ecto.Schema
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field(:type, :string)
- end
-
- def changeset(%__MODULE__{} = struct, attrs) do
- struct
- |> cast(attrs, [:type])
- |> validate_required([:type])
- end
- end
-
- defmodule Table do
- use Ecto.Schema
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field(:name, :string)
- end
-
- def changeset(%__MODULE__{} = struct, attrs) do
- struct
- |> cast(attrs, [:name])
- |> validate_required([:name])
- |> validate_format(:name, ~r/[a-z0-9_\-]/)
- end
- end
-
- defmodule Alias do
- use Ecto.Schema
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field(:name, :string)
- end
-
- def changeset(%__MODULE__{} = struct, attrs) do
- struct
- |> cast(attrs, [:name])
- |> validate_required(:name)
- |> validate_format(:name, Sanbase.Metric.Registry.metric_regex())
- |> validate_length(:name, min: 3, max: 100)
- end
- end
-
- defmodule Doc do
- use Ecto.Schema
- import Ecto.Changeset
-
- @primary_key false
- embedded_schema do
- field(:link, :string)
- end
-
- def changeset(%__MODULE__{} = struct, attrs) do
- struct
- |> cast(attrs, [:link])
- |> validate_required([:link])
- |> validate_format(:link, ~r|https://academy.santiment.net|)
- end
- end
-
@type t :: %__MODULE__{
id: integer(),
metric: String.t(),
@@ -112,9 +49,15 @@ defmodule Sanbase.Metric.Registry do
deprecation_note: String.t(),
data_type: String.t(),
docs: [%Doc{}],
+ is_verified: boolean(),
+ sync_status: String.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
+
+ @derive {Jason.Encoder,
+ except: [:__struct__, :__meta__, :id, :inserted_at, :updated_at, :change_suggestions]}
+
@timestamps_opts [type: :utc_datetime]
schema "metric_registry" do
# How the metric is exposed to external users
@@ -146,9 +89,12 @@ defmodule Sanbase.Metric.Registry do
field(:is_deprecated, :boolean, default: false)
field(:hard_deprecate_after, :utc_datetime, default: nil)
field(:deprecation_note, :string, default: nil)
-
field(:data_type, :string, default: "timeseries")
+ # Sync-related fields
+ field(:is_verified, :boolean)
+ field(:sync_status, :string)
+
embeds_many(:tables, Table, on_replace: :delete)
embeds_many(:selectors, Selector, on_replace: :delete)
embeds_many(:required_selectors, Selector, on_replace: :delete)
@@ -179,10 +125,12 @@ defmodule Sanbase.Metric.Registry do
:is_hidden,
:is_template,
:is_timebound,
+ :is_verified,
:metric,
:min_interval,
:sanbase_min_plan,
:sanapi_min_plan,
+ :sync_status,
:parameters
])
|> cast_embed(:selectors,
@@ -235,6 +183,7 @@ defmodule Sanbase.Metric.Registry do
|> validate_inclusion(:exposed_environments, ["all", "none", "stage", "prod"])
|> validate_inclusion(:access, ["free", "restricted"])
|> validate_change(:min_interval, &Validation.validate_min_interval/2)
+ |> validate_change(:sync_status, &Validation.validate_sync_status/2)
|> validate_inclusion(:sanbase_min_plan, ["free", "pro", "max"])
|> validate_inclusion(:sanapi_min_plan, ["free", "pro", "max"])
|> Validation.validate_template_fields()
@@ -297,7 +246,14 @@ defmodule Sanbase.Metric.Registry do
need to be resolved, or aliases need to be applied.
"""
@spec all() :: [t()]
- def all(), do: Sanbase.Repo.all(__MODULE__)
+ def all() do
+ query =
+ from(m in __MODULE__,
+ order_by: [asc: m.id]
+ )
+
+ Sanbase.Repo.all(query)
+ end
@doc ~s"""
Resolve all the metric registry records.
@@ -324,7 +280,7 @@ defmodule Sanbase.Metric.Registry do
:update_metric_registry,
:delete_metric_registry
] do
- if Keyword.get(opts, :emit_event?, true) do
+ if Keyword.get(opts, :emit_event, true) do
emit_event(result, event_type, %{})
else
result
diff --git a/lib/sanbase/metric/registry/validation.ex b/lib/sanbase/metric/registry/validation.ex
index d468de1508..69fdf22700 100644
--- a/lib/sanbase/metric/registry/validation.ex
+++ b/lib/sanbase/metric/registry/validation.ex
@@ -1,6 +1,17 @@
defmodule Sanbase.Metric.Registry.Validation do
import Ecto.Changeset
+ @sync_statuses ["synced", "not_synced"]
+ def validate_sync_status(:sync_status, status) do
+ if status in @sync_statuses do
+ []
+ else
+ [
+ sync_status: "The provided sync_status #{status} is not supported."
+ ]
+ end
+ end
+
def validate_min_interval(:min_interval, min_interval) do
if Sanbase.DateTimeUtils.valid_compound_duration?(min_interval) do
if Sanbase.DateTimeUtils.str_to_days(min_interval) > 30 do
@@ -19,31 +30,6 @@ defmodule Sanbase.Metric.Registry.Validation do
end
end
- def validate_min_plan(:min_plan, min_plan) do
- map_keys = Map.keys(min_plan) |> Enum.sort()
- map_values = Map.values(min_plan) |> Enum.uniq() |> Enum.sort()
-
- cond do
- map_size(min_plan) == 0 ->
- []
-
- map_keys != ["SANAPI", "SANBASE"] ->
- [
- min_plan:
- "The keys for min_plan must be 'SANBASE' and 'SANAPI'. Got #{Enum.join(map_keys, ", ")} instead"
- ]
-
- Enum.any?(map_values, &(&1 not in ["free", "pro"])) ->
- [
- min_plan:
- "The values for the min_plan elements must be 'free' or 'restricted'. Got #{Enum.join(map_values, ", ")} instead"
- ]
-
- true ->
- []
- end
- end
-
def validate_template_fields(%Ecto.Changeset{} = changeset) do
is_template = get_field(changeset, :is_template)
parameters = get_field(changeset, :parameters)
diff --git a/lib/sanbase_web/components/core_components.ex b/lib/sanbase_web/components/core_components.ex
index d67690ea32..aaaf4ec7dd 100644
--- a/lib/sanbase_web/components/core_components.ex
+++ b/lib/sanbase_web/components/core_components.ex
@@ -39,6 +39,7 @@ defmodule SanbaseWeb.CoreComponents do
attr(:id, :string, required: true)
attr(:show, :boolean, default: false)
attr(:on_cancel, JS, default: %JS{})
+ attr(:max_modal_width, :string, default: "max-w-3xl")
slot(:inner_block, required: true)
def modal(assigns) do
@@ -60,7 +61,7 @@ defmodule SanbaseWeb.CoreComponents do
tabindex="0"
>
-
+
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
diff --git a/lib/sanbase_web/controllers/metric_registry_controller.ex b/lib/sanbase_web/controllers/metric_registry_controller.ex
new file mode 100644
index 0000000000..e49d300380
--- /dev/null
+++ b/lib/sanbase_web/controllers/metric_registry_controller.ex
@@ -0,0 +1,36 @@
+defmodule SanbaseWeb.MetricRegistryController do
+ use SanbaseWeb, :controller
+
+ def export_json(conn, _params) do
+ conn
+ |> resp(200, get_metric_registry_json())
+ |> send_resp()
+ end
+
+ defp get_metric_registry_json() do
+ Sanbase.Metric.Registry.all()
+ |> Enum.take(1)
+ |> Enum.map(&transform/1)
+
+ # |> Enum.map(&Jason.encode!/1)
+ # |> Enum.intersperse("\n")
+ end
+
+ defp transform(struct) when is_struct(struct) do
+ struct
+ |> Map.from_struct()
+ |> Map.drop([:__meta__, :inserted_at, :updated_at, :change_suggestions])
+ |> Map.new(fn
+ {k, v} when is_list(v) ->
+ {k, Enum.map(v, &transform/1)}
+
+ {k, v} when is_map(v) ->
+ {k, transform(v)}
+
+ {k, v} ->
+ {k, v}
+ end)
+ end
+
+ defp transform(data), do: data
+end
diff --git a/lib/sanbase_web/live/available_metrics/available_metrics_description.ex b/lib/sanbase_web/live/available_metrics/available_metrics_description.ex
index aca911dc36..953be53255 100644
--- a/lib/sanbase_web/live/available_metrics/available_metrics_description.ex
+++ b/lib/sanbase_web/live/available_metrics/available_metrics_description.ex
@@ -50,6 +50,17 @@ defmodule SanbaseWeb.AvailableMetricsDescription do
"""
end
+ def get_popover_text(%{key: "Human Readable Name"} = assigns) do
+ ~H"""
+
+ The name of the metric formatted in a way that is suitable for showing
+ in Web UI and in texts shown to end clients. The name is capitalized, underscores
+ are replaced with spaces and other formatting is applied.
+ Example: The human readable name of 'price_usd' is 'USD Price'
+
+ """
+ end
+
def get_popover_text(%{key: "Clickhouse Table"} = assigns) do
~H"""
@@ -378,4 +389,35 @@ defmodule SanbaseWeb.AvailableMetricsDescription do
"""
end
+
+ def get_popover_text(%{key: "Verified Status"} = assigns) do
+ ~H"""
+
+ After a change request is approved and its changes are applied to the databse
+ record, the metric is moved into a Unverified state. Someone will need to manually
+ verify the metric via the UI (after testing the changes).
+ Only verified metrics can be deployed from stage to prod.
+
+ """
+ end
+
+ def get_popover_text(%{key: "Exposed Environments"} = assigns) do
+ ~H"""
+
+ One of: none, all, stage, prod.
+ Controls on which deployment environment the metric is visible.
+
+ """
+ end
+
+ def get_popover_text(%{key: "Sync Status"} = assigns) do
+ ~H"""
+
+ Controls the deployment process of metrics. When a change request is approved
+ and applied, the metric is moved to 'not_synced' state indicating that the change
+ is not synced with the other environment.
+ Only metrics in not_synced state are deployed from stage to prod.
+
+ """
+ end
end
diff --git a/lib/sanbase_web/live/metric_registry/metric_registry_change_suggestions_live.ex b/lib/sanbase_web/live/metric_registry/metric_registry_change_suggestions_live.ex
index 5d7257ec91..fd42e685f2 100644
--- a/lib/sanbase_web/live/metric_registry/metric_registry_change_suggestions_live.ex
+++ b/lib/sanbase_web/live/metric_registry/metric_registry_change_suggestions_live.ex
@@ -3,6 +3,7 @@ defmodule SanbaseWeb.MetricRegistryChangeSuggestionsLive do
alias SanbaseWeb.AdminFormsComponents
alias Sanbase.Metric.Registry.ChangeSuggestion
+ alias SanbaseWeb.AvailableMetricsComponents
@impl true
def mount(_params, _session, socket) do
@@ -16,12 +17,17 @@ defmodule SanbaseWeb.MetricRegistryChangeSuggestionsLive do
def render(assigns) do
~H"""
- """
- end
-
@impl true
def handle_event("apply_filters", params, socket) do
- visible_metrics =
+ visible_metrics_ids =
socket.assigns.metrics
|> maybe_apply_filter(:match_metric, params)
|> maybe_apply_filter(:match_table, params)
+ |> maybe_apply_filter(:only_unverified, params)
+ |> Enum.map(& &1.id)
{:noreply,
socket
|> assign(
- visible_metrics: visible_metrics,
+ visible_metrics_ids: visible_metrics_ids,
filter: params
)}
end
+ def handle_event("show_verified_changes_modal", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(show_verified_changes_modal: true)}
+ end
+
+ def handle_event("hide_show_verified_changes_modal", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(show_verified_changes_modal: false)}
+ end
+
+ def handle_event(
+ "update_status_is_verified",
+ %{"metric_registry_id" => id, "is_verified" => bool},
+ socket
+ ) do
+ verified_metrics_updates_map =
+ Map.update(
+ socket.assigns.verified_metrics_updates_map,
+ id,
+ # This will be invoked only the first time the metric registry is updated
+ %{old: Enum.find(socket.assigns.metrics, &(&1.id == id)).is_verified, new: bool},
+ fn map -> Map.put(map, :new, bool) end
+ )
+
+ # Keep only the IDs and not the full metric list otherwise this list needs to be
+ # updated after each time update_metric/4 is called or the metrics list is mutated
+ # in any other way.
+ changed_metrics_ids =
+ verified_metrics_updates_map
+ |> Enum.reduce([], fn {id, map}, acc ->
+ if map.new != map.old, do: [id | acc], else: acc
+ end)
+
+ {:noreply,
+ assign(socket,
+ changed_metrics_ids: changed_metrics_ids,
+ verified_metrics_updates_map: verified_metrics_updates_map,
+ metrics: update_metric(socket.assigns.metrics, id, :is_verified, bool)
+ )}
+ end
+
+ def handle_event("confirm_verified_changes_update", _params, socket) do
+ for metric <- socket.assigns.metrics, metric.id in socket.assigns.changed_metrics_ids do
+ map = Map.get(socket.assigns.verified_metrics_updates_map, metric.id)
+
+ # Explicitly put the old is_verified status in the first argument otherwise Ecto
+ # will decide that the value does not change and will not mutate the DB
+ {:ok, _} =
+ Sanbase.Metric.Registry.update(
+ %{metric | is_verified: map.old},
+ %{is_verified: map.new},
+ emit_event: false
+ )
+ end
+
+ {:noreply,
+ socket
+ |> assign(
+ changed_metrics_ids: [],
+ verified_metrics_updates_map: %{},
+ show_verified_changes_modal: false
+ )
+ |> put_flash(
+ :info,
+ "Suggessfully updated the is_verified status of #{length(socket.assigns.changed_metrics_ids)} metric metricss"
+ )}
+ end
+
+ defp list_metrics_verified_status_changed(assigns) do
+ ~H"""
+