diff --git a/VERSION b/VERSION index ff2fd4fbe..9eadd6baa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.5 \ No newline at end of file +1.8.6 \ No newline at end of file diff --git a/assets/css/app.scss b/assets/css/app.scss index ebc81ae59..9ad737513 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -1,5 +1,7 @@ /* This file is for your main application css. */ @import "~bootstrap/scss/bootstrap"; +@import "../../deps/live_monaco_editor/priv/static/live_monaco_editor.min.css"; + @import "named_variables"; @import "dashboard"; @import "header"; diff --git a/assets/js/app.js b/assets/js/app.js index 7b7484ed3..1e6d0579e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,6 +1,7 @@ import "../css/app.scss"; import { Socket } from "phoenix"; import "../css/tailwind.css"; + import "bootstrap"; import ClipboardJS from "clipboard"; import * as Dashboard from "./dashboard"; @@ -19,6 +20,8 @@ import sourceLiveViewHooks from "./source_lv_hooks"; import logsLiveViewHooks from "./log_event_live_hooks"; import $ from "jquery"; import moment from "moment"; +import { CodeEditorHook } from "../../deps/live_monaco_editor/priv/static/live_monaco_editor.esm" + // set moment globally before daterangepicker window.moment = moment; @@ -50,6 +53,8 @@ const hooks = { ...logsLiveViewHooks, ...LiveModalHooks, ...BillingHooks, + CodeEditorHook + }; let liveSocket = new LiveSocket("/live", Socket, { diff --git a/assets/package-lock.json b/assets/package-lock.json index f0bd55d4a..48ce771d7 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -38,20 +38,23 @@ "glob": "^10.3.1", "postcss": "^8.4.31", "sass": "^1.58.3", - "tailwindcss": "^3.3.2" + "tailwindcss": "^3.4.10" } }, "../deps/phoenix": { - "version": "0.0.1" + "version": "1.7.10", + "license": "MIT" }, "../deps/phoenix_html": { - "version": "0.0.1" + "version": "3.3.3" }, "../deps/phoenix_live_react": { - "version": "0.0.1" + "version": "0.4.2", + "license": "MIT" }, "../deps/phoenix_live_view": { - "version": "0.0.1" + "version": "0.20.1", + "license": "MIT" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -1685,9 +1688,9 @@ } }, "node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -2046,9 +2049,9 @@ } }, "node_modules/jiti": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", - "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", "dev": true, "bin": { "jiti": "bin/jiti.js" @@ -3078,9 +3081,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", - "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -3088,10 +3091,10 @@ "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.2.12", + "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.18.2", + "jiti": "^1.21.0", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -3103,7 +3106,6 @@ "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, @@ -4517,9 +4519,9 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", @@ -4787,9 +4789,9 @@ } }, "jiti": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", - "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", "dev": true }, "jquery": { @@ -5505,9 +5507,9 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, "tailwindcss": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", - "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "dev": true, "requires": { "@alloc/quick-lru": "^5.2.0", @@ -5515,10 +5517,10 @@ "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.2.12", + "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.18.2", + "jiti": "^1.21.0", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -5530,7 +5532,6 @@ "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, diff --git a/assets/package.json b/assets/package.json index 7e99b6b64..0995486c7 100644 --- a/assets/package.json +++ b/assets/package.json @@ -38,6 +38,6 @@ "glob": "^10.3.1", "postcss": "^8.4.31", "sass": "^1.58.3", - "tailwindcss": "^3.3.2" + "tailwindcss": "^3.4.10" } } diff --git a/lib/logflare_web/live/query_live.ex b/lib/logflare_web/live/query_live.ex new file mode 100644 index 000000000..437b6deb1 --- /dev/null +++ b/lib/logflare_web/live/query_live.ex @@ -0,0 +1,243 @@ +defmodule LogflareWeb.QueryLive do + @moduledoc false + use LogflareWeb, :live_view + use Phoenix.Component + + require Logger + + alias Logflare.Endpoints + alias Logflare.Users + + def render(assigns) do + ~H""" + <.subheader> + <:path> + ~/<.subheader_path_link live_patch to={~p"/query"}>query + + + +
+

+ Query your data with BigQuery SQL directly. You can refer to source names directly in your SELECT queries, for example
+ SELECT datetime(timestamp) as timestamp, event_message, metadata from `MyApp.Logs` + Some pointers: +

+ Read the docs + to find out more about querying Logflare with BigQuery SQL +

+
+
+ <.form for={%{}} phx-submit="run-query" class="tw-min-h-[80px] tw-flex tw-flex-col tw-gap-4"> + "on", + "language" => "sql", + "fontSize" => 12, + "padding" => %{ + "top" => 14 + }, + "fixedOverflowWidgets" => false, + "contextmenu" => false, + "hideCursorInOverviewRuler" => true, + "smoothScrolling" => true, + "scrollbar" => %{ + "vertical" => "auto", + "horizontal" => "hidden", + "verticalScrollbarSize" => 6 + }, + "lineNumbers" => "off", + "glyphMargin" => false, + "lineNumbersMinChars" => 0, + "folding" => false, + "roundedSelection" => true, + "editorClassName" => "", + "minimap" => %{ + "enabled" => false + }, + "placeholder" => "SELECT timestamp, event_message from `MyApp.Source`" + } + ) + } + /> +
+ <%= submit("Run query", class: "btn btn-secondary") %> +
+ + +
+ <.alert variant="warning"> + SQL Parse error! +
+ <%= @parse_error_message %> + +
+
+ +
+

Query result

+

+ No rows returned from query. Try adjusting your query and try again! +

+
+ <% keys = Map.keys(hd(@query_result_rows)) |> Enum.sort() %> + + + + + + + + + + + +
<%= k %>
+ <%= case value = Map.get(row, k) do %> + <% value when is_map(value) or is_list(value) -> %> + + <% value -> %> + + <%= value %> + + <% end %> + + +
+
+
+ """ + end + + def mount(%{}, %{"user_id" => user_id} = params, socket) do + user = Users.get(user_id) + + query_string = + Map.get( + params, + "q", + "SELECT id, timestamp, metadata, event_message \nFROM `YourSource` \nWHERE timestamp > '#{DateTime.utc_now() |> DateTime.to_iso8601()}'" + ) + + socket = + socket + |> assign(:user_id, user_id) + |> assign(:user, user) + |> assign(:query_result_rows, nil) + |> assign(:parse_error_message, nil) + |> assign(:query_string, query_string) + + {:ok, socket} + end + + def handle_params(params, _uri, socket) do + query_string = params["q"] || socket.assigns.query_string + + socket = + socket + |> assign(:query_string, query_string) + |> then(fn + socket when query_string != "" -> + case Endpoints.parse_query_string(query_string) do + {:ok, _} -> + socket + + {:error, err} -> + assign( + socket, + :parse_error_message, + if(is_binary(err), do: err, else: inspect(err)) + ) + |> assign(:query_result_rows, nil) + end + + socket -> + socket + end) + + {:noreply, socket} + end + + def handle_event( + "run-query", + %{"live_monaco_editor" => %{"query" => query_string}}, + %{assigns: %{user: user}} = socket + ) do + socket = + run_query(socket, user, query_string) + |> push_patch(to: ~p"/query?#{%{q: query_string}}") + + {:noreply, socket} + end + + def handle_event( + "parse-query", + %{"value" => query_string}, + socket + ) do + socket = + case Endpoints.parse_query_string(query_string) do + {:ok, _} -> + socket + |> assign(:parse_error_message, nil) + + {:error, err} -> + error = if(is_binary(err), do: err, else: inspect(err)) + + socket + |> assign(:parse_error_message, error) + end + |> assign(:query_string, query_string) + + {:noreply, socket} + end + + def handle_event("parse-query", %{"_target" => ["live_monaco_editor", _]}, socket) do + # ignore change events from the editor field + {:noreply, socket} + end + + defp run_query(socket, user, query_string) do + case Endpoints.run_query_string(user, {:bq_sql, query_string}, params: %{}) do + {:ok, %{rows: rows}} -> + socket + |> put_flash(:info, "Ran query successfully") + |> assign(:query_result_rows, rows) + + {:error, err} -> + socket + |> put_flash(:error, "Error occured when running query: #{inspect(err)}") + end + end +end diff --git a/lib/logflare_web/router.ex b/lib/logflare_web/router.ex index e3830c10d..4b623311c 100644 --- a/lib/logflare_web/router.ex +++ b/lib/logflare_web/router.ex @@ -176,6 +176,12 @@ defmodule LogflareWeb.Router do live("/backends/:id/edit", BackendsLive, :edit) end + scope "/query", LogflareWeb do + pipe_through([:browser, :require_auth]) + + live("/", QueryLive, :index) + end + scope "/endpoints", LogflareWeb do pipe_through([:browser, :require_auth]) diff --git a/lib/logflare_web/templates/source/dashboard.html.heex b/lib/logflare_web/templates/source/dashboard.html.heex index 56a2fa263..d17d6ba13 100644 --- a/lib/logflare_web/templates/source/dashboard.html.heex +++ b/lib/logflare_web/templates/source/dashboard.html.heex @@ -87,7 +87,10 @@
- <.link href={~p"/sources/new"} class="btn btn-primary btn-sm" id="new-source-button"> + <.link href={~p"/query"} class="btn btn-primary btn-sm"> + Run a query + + <.link href={~p"/sources/new"} class="btn btn-primary btn-sm"> New source
diff --git a/mix.exs b/mix.exs index 91c15b993..44a31629e 100644 --- a/mix.exs +++ b/mix.exs @@ -225,7 +225,8 @@ defmodule Logflare.Mixfile do {:opentelemetry_api, "~> 1.2"}, {:opentelemetry_exporter, "~> 1.6"}, {:opentelemetry_phoenix, "~> 1.1"}, - {:opentelemetry_cowboy, "~> 0.2"} + {:opentelemetry_cowboy, "~> 0.2"}, + {:live_monaco_editor, "~> 0.1"} ] end diff --git a/mix.lock b/mix.lock index fc379db6f..24bae1459 100644 --- a/mix.lock +++ b/mix.lock @@ -74,6 +74,7 @@ "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "key_tools": {:hex, :key_tools, "0.4.1", "4bdf5a39190dc465e58f0c44784b7bb5300bafbbfff2b4ada4d7ec3bfde8d470", [:mix], [], "hexpm", "1a5afce636176481acec2db91066e68af5bf3c512327292a14078ca1aad1a57e"}, "libcluster": {:hex, :libcluster, "3.3.3", "a4f17721a19004cfc4467268e17cff8b1f951befe428975dd4f6f7b84d927fe0", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7c0a2275a0bb83c07acd17dab3c3bfb4897b145106750eeccc62d302e3bdfee5"}, + "live_monaco_editor": {:hex, :live_monaco_editor, "0.1.8", "149c02cab1c595fe2d2049cffb0a424db2a329a5fa848ee8b778d5acd8694733", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "9a56e88a61cdf6d58081627e4842f4e3a8e3a75dd8749f271a464164ad4530f1"}, "local_cluster": {:hex, :local_cluster, "1.2.1", "8eab3b8a387680f0872eacfb1a8bd5a91cb1d4d61256eec6a655b07ac7030c73", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "aae80c9bc92c911cb0be085fdeea2a9f5b88f81b6bec2ff1fec244bb0acc232c"}, "logflare_api_client": {:hex, :logflare_api_client, "0.3.5", "c427ebf65a8402d68b056d4a5ef3e1eb3b90c0ad1d0de97d1fe23807e0c1b113", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:finch, "~> 0.10", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "16d29abcb80c4f72745cdf943379da02a201504813c3aa12b4d4acb0302b7723"}, "logflare_etso": {:hex, :logflare_etso, "1.1.2", "040bd3e482aaf0ed20080743b7562242ec5079fd88a6f9c8ce5d8298818292e9", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "ab96be42900730a49b132891f43a9be1d52e4ad3ee9ed9cb92565c5f87345117"}, diff --git a/test/logflare_web/live_views/query_live_test.exs b/test/logflare_web/live_views/query_live_test.exs new file mode 100644 index 000000000..54a62dba6 --- /dev/null +++ b/test/logflare_web/live_views/query_live_test.exs @@ -0,0 +1,43 @@ +defmodule LogflareWeb.QueryLiveTest do + @moduledoc false + use LogflareWeb.ConnCase + + setup %{conn: conn} do + insert(:plan) + user = insert(:user) + conn = login_user(conn, user) + {:ok, user: user, conn: conn} + end + + describe "query page" do + test "run a valid query", %{conn: conn} do + GoogleApi.BigQuery.V2.Api.Jobs + |> expect(:bigquery_jobs_query, 1, fn _conn, _proj_id, _opts -> + {:ok, TestUtils.gen_bq_response([%{"ts" => "some-data"}])} + end) + + {:ok, view, _html} = live(conn, "/query") + + # link to show + view + |> element("form") + |> render_submit(%{ + live_monaco_editor: %{ + query: "select current_timestamp() as ts" + } + }) =~ "Ran query successfully" + + assert_patch(view) =~ ~r/current_timestamp/ + assert render(view) =~ "some-data" + end + + test "parser error", %{conn: conn} do + {:ok, view, _html} = live(conn, "/query") + + assert view + |> render_hook("parse-query", %{ + value: "select current_datetime() order-by invalid" + }) =~ "parser error" + end + end +end