diff --git a/lib/ret/node_stat.ex b/lib/ret/node_stat.ex index 330b18d74..aff540a6a 100644 --- a/lib/ret/node_stat.ex +++ b/lib/ret/node_stat.ex @@ -24,6 +24,7 @@ defmodule Ret.NodeStat do ]) end + @spec max_ccu_for_time_range(DateTime.t(), DateTime.t()) :: number() def max_ccu_for_time_range(start_time, end_time) do start_time_truncated = start_time |> NaiveDateTime.truncate(:second) end_time_truncated = end_time |> NaiveDateTime.truncate(:second) @@ -36,6 +37,8 @@ defmodule Ret.NodeStat do where: stat.measured_at < ^end_time_truncated ) + # Can I check that the db actually has the time range? So we could technically fill in previous data. + if max_ccu === nil, do: 0, else: max_ccu end end diff --git a/lib/ret_web/controllers/api-internal/v1/AuthTokenController.ex b/lib/ret_web/controllers/api-internal/v1/auth_token_controller.ex similarity index 100% rename from lib/ret_web/controllers/api-internal/v1/AuthTokenController.ex rename to lib/ret_web/controllers/api-internal/v1/auth_token_controller.ex diff --git a/lib/ret_web/controllers/api-internal/v1/hub_stats_controller.ex b/lib/ret_web/controllers/api-internal/v1/hub_stats_controller.ex new file mode 100644 index 000000000..dc9da2904 --- /dev/null +++ b/lib/ret_web/controllers/api-internal/v1/hub_stats_controller.ex @@ -0,0 +1,24 @@ +defmodule RetWeb.ApiInternal.V1.HubStatsController do + use RetWeb, :controller + alias Ret.NodeStat + + # Params start_time and end_time should be in iso format such as "2000-02-28 23:00:13" + # or what is returned from NaiveDateTime.to_string() + def hub_stats(conn, %{"start_time" => start_time_str, "end_time" => end_time_str}) do + conn = put_resp_header(conn, "content-type", "application/json") + + case Ret.Storage.storage_used() do + {:ok, storage_used_kb} when is_number(storage_used_kb) -> + max_ccu = + NodeStat.max_ccu_for_time_range( + start_time_str |> NaiveDateTime.from_iso8601!(), + end_time_str |> NaiveDateTime.from_iso8601!() + ) + + conn |> send_resp(200, %{max_ccu: max_ccu, storage_mb: storage_used_kb / 1024} |> Poison.encode!()) + + _ -> + send_resp(conn, 503, %{error: :storage_usage_unavailable} |> Poison.encode!()) + end + end +end diff --git a/lib/ret_web/router.ex b/lib/ret_web/router.ex index 1f9c2fa83..3a9ae07bc 100644 --- a/lib/ret_web/router.ex +++ b/lib/ret_web/router.ex @@ -81,8 +81,8 @@ defmodule RetWeb.Router do end pipeline :graphql do - plug RetWeb.ApiTokenAuthPipeline - plug RetWeb.AddAbsintheContext + plug(RetWeb.ApiTokenAuthPipeline) + plug(RetWeb.AddAbsintheContext) end scope "/health", RetWeb do @@ -193,8 +193,8 @@ defmodule RetWeb.Router do pipe_through [:parsed_body, :api, :public_api_access, :graphql] ++ if(Mix.env() == :prod, do: [:ssl_only], else: []) - forward "/graphiql", Absinthe.Plug.GraphiQL, json_codec: Jason, schema: RetWeb.Schema - forward "/", Absinthe.Plug, json_codec: Jason, schema: RetWeb.Schema + forward("/graphiql", Absinthe.Plug.GraphiQL, json_codec: Jason, schema: RetWeb.Schema) + forward("/", Absinthe.Plug, json_codec: Jason, schema: RetWeb.Schema) end scope "/api-internal", RetWeb do @@ -208,6 +208,7 @@ defmodule RetWeb.Router do post "/rewrite_assets", ApiInternal.V1.RewriteAssetsController, :post put "/change_email_for_login", ApiInternal.V1.LoginEmailController, :update post "/make_auth_token_for_email", ApiInternal.V1.AuthTokenController, :post + get("/hub_stats", ApiInternal.V1.HubStatsController, :show) end end diff --git a/test/ret_web/controllers/api-internal/hub_stats_controller_test.exs b/test/ret_web/controllers/api-internal/hub_stats_controller_test.exs new file mode 100644 index 000000000..45425609c --- /dev/null +++ b/test/ret_web/controllers/api-internal/hub_stats_controller_test.exs @@ -0,0 +1,68 @@ +defmodule RetWeb.ApiInternal.V1.StorageControllerTest do + use RetWeb.ConnCase + import Ret.TestHelpers + + @dashboard_access_header "x-ret-dashboard-access-key" + @dashboard_access_key "test-key" + + setup_all do + merge_module_config(:ret, RetWeb.Plugs.DashboardHeaderAuthorization, %{ + dashboard_access_key: @dashboard_access_key + }) + + on_exit(fn -> + Ret.TestHelpers.merge_module_config(:ret, RetWeb.Plugs.DashboardHeaderAuthorization, %{ + dashboard_access_key: nil + }) + end) + end + + test "hub stats endpoint responds with cached storage value", %{conn: conn} do + mock_storage_used(0) + resp = request_hub_stats(conn) + assert resp["storage_mb"] === 0.0 and resp["max_ccu"] === 0.0 + + mock_storage_used(10) + resp = request_hub_stats(conn) + assert resp["storage_mb"] === 10.0 and resp["max_ccu"] === 0.0 + end + + + test "hub stats endpoint returns 401 without access key header", %{conn: conn} do + resp = get(conn, "/api-internal/v1/hub_stats") + assert resp.status === 401 + end + + test "hub stats endpoint returns 401 with incorrect access key", %{conn: conn} do + resp = + conn + |> put_req_header(@dashboard_access_header, "incorrect-access-key") + |> get("/api-internal/v1/hub_stats") + + assert resp.status === 401 + end + + test "hub stats endpoint errors with 503 status when storage usage is not available", %{conn: conn} do + mock_storage_used(nil) + resp = request_hub_stats(conn, expected_status: 503) + assert resp["error"] === "storage_usage_unavailable" + end + + # The Ret.Storage module relies on a cached value to retrieve storage usage via Ret.StorageUsed. + # Since we mainly care about testing the endpoint here, we use the cache to mock the usage value + # and ensure that the endpoint returns it as expected. + defp mock_storage_used(nil), do: Cachex.put(:storage_used, :storage_used, nil) + + defp mock_storage_used(storage_used_mb), + do: Cachex.put(:storage_used, :storage_used, storage_used_mb * 1024) + + defp request_hub_stats(conn, opts \\ [expected_status: 200]) do + {:ok, start_time} = NaiveDateTime.utc_now() |> NaiveDateTime.to_date() |> NaiveDateTime.new(Time.new(0,0,0,0)) + {:ok, end_time} = NaiveDateTime.utc_now() |> NaiveDateTime.to_date() |> Date.add(1) |> NaiveDateTime.new(Time.new(0,0,0,0)) + + conn + |> put_req_header(@dashboard_access_header, @dashboard_access_key) + |> get("/api-internal/v1/hub_stats", %{start_time: start_time, end_time: end_time}) + |> json_response(opts[:expected_status]) + end +end