diff --git a/lib/cleat/cli.ex b/lib/cleat/cli.ex index 152259a..19aa06c 100644 --- a/lib/cleat/cli.ex +++ b/lib/cleat/cli.ex @@ -68,6 +68,7 @@ defmodule Cleat.CLI do grep: :string, unit: :string, query: :string, + sql: :string, severity: :string, min_severity: :string, until: :string, @@ -209,6 +210,7 @@ defmodule Cleat.CLI do [--runtime R] [--apt pkg,pkg] Edit repo / branch / auto-deploy / indexing / host / port / runtime / apt packages apps logs APP [--tail N] [--since S] [--grep T] [--follow] Runtime logs (systemd unit) + apps query APP SQL [--limit N] Read-only SQL (Postgres or SQLite) servers logs ID [--unit U] [--tail N] [--since S] [--grep T] [--follow] Host journal (all units, or one with --unit) @@ -224,6 +226,8 @@ defmodule Cleat.CLI do signals health [APP] Overview of app health signals metrics APP [--range 1h|6h|24h|1d] RED metrics and deploy markers + signals pages APP [--range 1h|6h|24h|1d] + Most requested HTTP paths signals alerts [list] Open default alerts signals alerts ack ID Confirm a firing alert signals traces APP [--trace-id ID] [--service NAME] diff --git a/lib/cleat/client.ex b/lib/cleat/client.ex index 1d4e272..c7312cd 100644 --- a/lib/cleat/client.ex +++ b/lib/cleat/client.ex @@ -61,6 +61,11 @@ defmodule Cleat.Client do request(client, :get, "/api/v1/apps/#{app}/logs", params: log_params(opts)) end + @doc "Read-only SQL against the app datastore (`POST /api/v1/apps/:id/query`)." + def query_app(%__MODULE__{} = client, app, attrs) when is_map(attrs) do + request(client, :post, "/api/v1/apps/#{app}/query", json: attrs) + end + def server_logs(%__MODULE__{} = client, id, opts \\ %{}) when is_map(opts) do request(client, :get, "/api/v1/servers/#{id}/logs", params: log_params(opts)) end @@ -93,6 +98,11 @@ defmodule Cleat.Client do request(client, :get, "/api/v1/signals/metrics", params: params) end + @doc "Most requested HTTP paths and visited pageviews (`GET /api/v1/signals/pages`)." + def signals_pages(%__MODULE__{} = client, params) when is_map(params) do + request(client, :get, "/api/v1/signals/pages", params: params) + end + @doc "Open signal alerts (`GET /api/v1/signals/alerts`)." def signals_alerts(%__MODULE__{} = client), do: request(client, :get, "/api/v1/signals/alerts") diff --git a/lib/cleat/commands/apps.ex b/lib/cleat/commands/apps.ex index 531599c..9ed2290 100644 --- a/lib/cleat/commands/apps.ex +++ b/lib/cleat/commands/apps.ex @@ -3,7 +3,7 @@ defmodule Cleat.Commands.Apps do alias Cleat.{Client, Commands, Output, Runtime} - @usage "usage: cleat apps list | cleat apps show APP | cleat apps create --name N --repo owner/repo --host H --server ID [--apt pkg,pkg] | cleat apps update APP [--branch B] [--auto-deploy|--no-auto-deploy] [--indexable|--no-indexable] [--host H] [--port N] [--repo owner/repo] [--runtime R] [--apt pkg,pkg] | cleat apps delete APP --yes | cleat apps logs APP [--tail N] [--since S] [--grep T] [--follow]" + @usage "usage: cleat apps list | cleat apps show APP | cleat apps create --name N --repo owner/repo --host H --server ID [--apt pkg,pkg] | cleat apps update APP [--branch B] [--auto-deploy|--no-auto-deploy] [--indexable|--no-indexable] [--host H] [--port N] [--repo owner/repo] [--runtime R] [--apt pkg,pkg] | cleat apps delete APP --yes | cleat apps logs APP [--tail N] [--since S] [--grep T] [--follow] | cleat apps query APP SQL [--limit N]" def run([], opts), do: list(opts) def run(["list"], opts), do: list(opts) @@ -12,6 +12,8 @@ defmodule Cleat.Commands.Apps do def run(["update", app | _rest], opts), do: update(app, opts) def run(["delete", app | _rest], opts), do: delete(app, opts) def run(["logs", app | _rest], opts), do: logs(app, opts) + def run(["query", app | rest], opts), do: query(app, rest, opts) + def run(["query"], _opts), do: {:error, @usage} def run(_args, _opts), do: {:error, @usage} defp list(opts) do @@ -207,6 +209,47 @@ defmodule Cleat.Commands.Apps do defp log_opts(opts), do: %{tail: opts[:tail], since: opts[:since], grep: opts[:grep]} + defp query(app, rest, opts) do + sql = opts[:sql] || Enum.join(rest, " ") |> String.trim() + + if sql == "" do + {:error, "usage: cleat apps query APP SQL [--limit N]"} + else + attrs = + %{"sql" => sql} + |> maybe_put_limit(opts) + + with {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.query_app(client, app, attrs) do + data = Commands.data(body) + if opts[:json], do: Output.json(data), else: print_query(data) + :ok + end + end + end + + defp maybe_put_limit(attrs, opts) do + case opts[:limit] do + nil -> attrs + limit -> Map.put(attrs, "limit", limit) + end + end + + defp print_query(data) do + engine = data["engine"] || "—" + truncated = if data["truncated"], do: "truncated=true", else: "truncated=false" + Output.info("#{data["slug"]} engine=#{engine} #{truncated}") + + columns = data["columns"] || [] + rows = data["rows"] || [] + + if columns == [] do + Output.info("(no rows)") + else + Output.table(Enum.map(rows, &List.wrap/1), columns) + end + end + defp print_lines([], app, true), do: Output.info("No runtime logs for #{app} yet. Following…") diff --git a/lib/cleat/commands/completions.ex b/lib/cleat/commands/completions.ex index 20bccd8..c36398c 100644 --- a/lib/cleat/commands/completions.ex +++ b/lib/cleat/commands/completions.ex @@ -10,10 +10,10 @@ defmodule Cleat.Commands.Completions do @subcommands %{ "servers" => ~w(list show create sync delete logs), - "apps" => ~w(list show create update delete logs), + "apps" => ~w(list show create update delete logs query), "env" => ~w(list set unset), "config" => ~w(list get set unset), - "signals" => ~w(health metrics alerts traces sampling) + "signals" => ~w(health metrics pages alerts traces sampling) } def run(["bash"], _opts), do: print(bash()) diff --git a/lib/cleat/commands/signals.ex b/lib/cleat/commands/signals.ex index f744ff5..683d66e 100644 --- a/lib/cleat/commands/signals.ex +++ b/lib/cleat/commands/signals.ex @@ -9,6 +9,7 @@ defmodule Cleat.Commands.Signals do usage: cleat signals health [APP] cleat signals metrics APP [--range 1h|6h|24h|1d] + cleat signals pages APP [--range 1h|6h|24h|1d] cleat signals alerts [list] cleat signals alerts ack ID cleat signals traces APP [--trace-id ID] [--service NAME] @@ -18,6 +19,8 @@ defmodule Cleat.Commands.Signals do def run(["health" | rest], opts), do: health(rest, opts) def run(["metrics", app | _rest], opts), do: metrics(app, opts) def run(["metrics"], _opts), do: {:error, @usage} + def run(["pages", app | _rest], opts), do: pages(app, opts) + def run(["pages"], _opts), do: {:error, @usage} def run(["alerts"], opts), do: alerts(opts) def run(["alerts", "list" | _rest], opts), do: alerts(opts) def run(["alerts", "ack", id | _rest], opts), do: ack(id, opts) @@ -48,6 +51,17 @@ defmodule Cleat.Commands.Signals do end end + defp pages(app, opts) do + params = compact(%{"app" => app, "range" => opts[:range]}) + + with {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.signals_pages(client, params) do + data = Commands.data(body) + if opts[:json], do: Output.json(data), else: print_pages(data) + :ok + end + end + defp alerts(opts) do with {:ok, client} <- Commands.client(opts), {:ok, body} <- Client.signals_alerts(client) do @@ -154,6 +168,33 @@ defmodule Cleat.Commands.Signals do Output.info("MARKERS #{markers_label(markers)}") end + defp print_pages(data) do + Output.info( + "#{data["slug"]} range=#{data["range"]} pageviews=#{data["pageviews"]} uniques=#{data["uniques"]}" + ) + + requested = data["requested"] || [] + visited = data["visited"] || [] + + if requested == [] do + Output.info("REQUESTED —") + else + Output.table( + Enum.map(requested, fn row -> [row["path"], row["requests"]] end), + ["PATH", "REQUESTS"] + ) + end + + if visited == [] do + Output.info("VISITED —") + else + Output.table( + Enum.map(visited, fn row -> [row["path"], row["pageviews"]] end), + ["PATH", "PAGEVIEWS"] + ) + end + end + defp print_alerts([]) do Output.info("No open alerts.") end diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index bab736e..47fc16d 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -434,6 +434,28 @@ defmodule Cleat.MCP.Tools do end) end }, + %{ + "name" => "signals_pages", + "description" => + "Most requested HTTP paths from Caddy access logs for one app, plus visited pageviews when analytics inject is on. range is 1h, 6h, 24h or 1d.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "app" => @app, + "range" => %{"type" => "string", "description" => "1h, 6h, 24h or 1d"}, + "panel" => @panel, + "token" => @token + }, + "required" => ["app"] + }, + "handler" => fn args -> + params = compact_params(%{"app" => args["app"], "range" => args["range"]}) + + with_client(args, fn client -> + with {:ok, body} <- Client.signals_pages(client, params), do: {:ok, data_text(body)} + end) + end + }, %{ "name" => "signals_alerts", "description" => "List open default alerts. Pass id to ack a firing alert.", @@ -664,6 +686,37 @@ defmodule Cleat.MCP.Tools do end) end }, + %{ + "name" => "apps_query", + "description" => + "Run one read-only SQL statement (SELECT, WITH, EXPLAIN, SHOW, PRAGMA) against the app's Postgres addon or local SQLite. Remote Turso/libSQL is unsupported.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "app" => @app, + "sql" => %{"type" => "string", "description" => "One read-only SQL statement"}, + "limit" => %{ + "type" => "integer", + "description" => "Max rows, 1-200 (default 50)" + }, + "panel" => @panel, + "token" => @token + }, + "required" => ["app", "sql"] + }, + "handler" => fn args -> + attrs = + case args["limit"] do + limit when is_integer(limit) -> %{"sql" => args["sql"], "limit" => limit} + _ -> %{"sql" => args["sql"]} + end + + with_client(args, fn client -> + with {:ok, body} <- Client.query_app(client, args["app"], attrs), + do: {:ok, data_text(body)} + end) + end + }, %{ "name" => "env_list", "description" => diff --git a/test/cleat/cli_mcp_test.exs b/test/cleat/cli_mcp_test.exs index 646bed9..bc09100 100644 --- a/test/cleat/cli_mcp_test.exs +++ b/test/cleat/cli_mcp_test.exs @@ -21,6 +21,8 @@ defmodule Cleat.CLIMcpTest do usage = CLI.usage() assert usage =~ "signals health" assert usage =~ "signals metrics" + assert usage =~ "signals pages" + assert usage =~ "apps query" assert usage =~ "signals alerts" end diff --git a/test/cleat/client_test.exs b/test/cleat/client_test.exs index 4870e49..4a4874d 100644 --- a/test/cleat/client_test.exs +++ b/test/cleat/client_test.exs @@ -42,6 +42,36 @@ defmodule Cleat.ClientTest do Client.signals_health(client, %{"app" => "catalogo"}) end + test "signals_pages GETs /api/v1/signals/pages" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/pages" + params = URI.decode_query(conn.query_string) + assert params["app"] == "new-lp" + assert params["range"] == "24h" + Req.Test.json(conn, %{"data" => %{"slug" => "new-lp", "requested" => []}}) + end) + + client = Client.new("https://panel.test", "tok") + + assert {:ok, %{"data" => %{"slug" => "new-lp"}}} = + Client.signals_pages(client, %{"app" => "new-lp", "range" => "24h"}) + end + + test "query_app POSTs /api/v1/apps/:id/query" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/v1/apps/new-lp/query" + assert Jason.decode!(Req.Test.raw_body(conn)) == %{"sql" => "SELECT 1", "limit" => 5} + Req.Test.json(conn, %{"data" => %{"engine" => "postgres", "rows" => []}}) + end) + + client = Client.new("https://panel.test", "tok") + + assert {:ok, %{"data" => %{"engine" => "postgres"}}} = + Client.query_app(client, "new-lp", %{"sql" => "SELECT 1", "limit" => 5}) + end + test "signals_traces GETs /api/v1/signals/traces" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "GET" diff --git a/test/cleat/commands/apps_test.exs b/test/cleat/commands/apps_test.exs index 36aa4d4..a558103 100644 --- a/test/cleat/commands/apps_test.exs +++ b/test/cleat/commands/apps_test.exs @@ -235,6 +235,41 @@ defmodule Cleat.Commands.AppsTest do assert message =~ "--yes" end + test "queries the app datastore" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/v1/apps/new-lp/query" + assert Jason.decode!(Req.Test.raw_body(conn)) == %{"sql" => "SELECT 1", "limit" => 5} + + Req.Test.json(conn, %{ + "data" => %{ + "slug" => "new-lp", + "engine" => "sqlite", + "columns" => ["?column?"], + "rows" => [["1"]], + "truncated" => false + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + Apps.run( + ["query", "new-lp", "SELECT", "1"], + Map.merge(@conn, %{limit: 5}) + ) + end) + + assert output =~ "engine=sqlite" + assert output =~ "1" + end + + test "query without SQL prints usage" do + assert {:error, message} = Apps.run(["query", "new-lp"], @conn) + assert message =~ "SQL" + end + test "prints runtime logs" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "GET" diff --git a/test/cleat/commands/signals_test.exs b/test/cleat/commands/signals_test.exs index f33d0ab..001c8c2 100644 --- a/test/cleat/commands/signals_test.exs +++ b/test/cleat/commands/signals_test.exs @@ -86,6 +86,42 @@ defmodule Cleat.Commands.SignalsTest do assert message =~ "usage" end + test "pages requires an app and forwards --range" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/signals/pages" + params = URI.decode_query(conn.query_string) + assert params["app"] == "new-lp" + assert params["range"] == "24h" + + Req.Test.json(conn, %{ + "data" => %{ + "app_id" => 56, + "slug" => "new-lp", + "range" => "24h", + "requested" => [%{"path" => "/", "requests" => 2}], + "visited" => [%{"path" => "/login", "pageviews" => 7}], + "pageviews" => 9, + "uniques" => 3 + } + }) + end) + + output = + capture_io(fn -> + assert :ok = Signals.run(["pages", "new-lp"], Map.put(@conn, :range, "24h")) + end) + + assert output =~ "new-lp" + assert output =~ "/" + assert output =~ "2" + assert output =~ "/login" + end + + test "pages without an app returns usage" do + assert {:error, message} = Signals.run(["pages"], @conn) + assert message =~ "usage" + end + test "alerts lists firing rows" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "GET" diff --git a/test/cleat/contract_test.exs b/test/cleat/contract_test.exs index 78910d4..e0006cf 100644 --- a/test/cleat/contract_test.exs +++ b/test/cleat/contract_test.exs @@ -16,6 +16,7 @@ defmodule Cleat.ContractTest do @used %{ "app" => ~w(id name slug github_repo branch host port runtime runtime_apt_packages auto_deploy indexable systemd_unit release_path data_dir server), + "app_query" => ~w(app_id slug engine columns rows truncated), "server" => ~w(id name host_ip ssh_user region provider deploy_mode instance_status bundle_name cpu_count ram_mb disk_gb), "deployment" => @@ -29,6 +30,7 @@ defmodule Cleat.ContractTest do "signal_health" => ~w(app_id slug name status reasons error_count previous_error_count preceding_release), "signal_metrics" => ~w(app_id slug range red host series deploy_markers), + "signal_pages" => ~w(app_id slug range requested visited pageviews uniques), "signal_alert" => ~w(id app_id slug rule status message channel fired_at acked_at delivered_at), "signal_incident" => ~w(app_id slug events), diff --git a/test/cleat/mcp/tools_test.exs b/test/cleat/mcp/tools_test.exs index 72a9ab8..d793617 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -35,6 +35,7 @@ defmodule Cleat.MCP.ToolsTest do "logs_search", "signals_health", "signals_metrics", + "signals_pages", "signals_alerts", "signals_traces", "signals_set_sampling", @@ -43,6 +44,7 @@ defmodule Cleat.MCP.ToolsTest do "apps_create", "apps_update", "apps_logs", + "apps_query", "env_list", "env_set", "env_unset", @@ -142,6 +144,40 @@ defmodule Cleat.MCP.ToolsTest do assert Jason.decode!(text)["lines"] == ["ERROR boom"] end + test "apps_query POSTs sql to /api/v1/apps/:id/query" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/v1/apps/new-lp/query" + assert Jason.decode!(Req.Test.raw_body(conn)) == %{"sql" => "SELECT 1", "limit" => 10} + + Req.Test.json(conn, %{ + "data" => %{ + "slug" => "new-lp", + "engine" => "sqlite", + "columns" => ["?column?"], + "rows" => [["1"]], + "truncated" => false + } + }) + end) + + assert {:ok, text} = + Tools.call("apps_query", %{ + "app" => "new-lp", + "sql" => "SELECT 1", + "limit" => 10, + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert Jason.decode!(text)["engine"] == "sqlite" + end + + test "apps_query requires sql" do + assert {:error, message} = Tools.call("apps_query", %{"app" => "new-lp"}) + assert message =~ "sql" + end + test "logs_search forwards the filters to /api/v1/logs" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "GET" @@ -193,6 +229,42 @@ defmodule Cleat.MCP.ToolsTest do assert message =~ "app" end + test "signals_pages requires an app" do + assert {:error, message} = Tools.call("signals_pages", %{}) + assert message =~ "app" + end + + test "signals_pages GETs /api/v1/signals/pages" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/pages" + params = URI.decode_query(conn.query_string) + assert params["app"] == "new-lp" + assert params["range"] == "24h" + + Req.Test.json(conn, %{ + "data" => %{ + "slug" => "new-lp", + "range" => "24h", + "requested" => [%{"path" => "/blog", "requests" => 1}], + "visited" => [], + "pageviews" => 0, + "uniques" => 0 + } + }) + end) + + assert {:ok, text} = + Tools.call("signals_pages", %{ + "app" => "new-lp", + "range" => "24h", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert %{"slug" => "new-lp", "requested" => [%{"path" => "/blog"}]} = Jason.decode!(text) + end + test "signals_alerts acks when id is present" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "POST" diff --git a/test/fixtures/api_contract.json b/test/fixtures/api_contract.json index 81aa53b..005139f 100644 --- a/test/fixtures/api_contract.json +++ b/test/fixtures/api_contract.json @@ -20,6 +20,14 @@ "server", "inserted_at" ], + "app_query": [ + "app_id", + "slug", + "engine", + "columns", + "rows", + "truncated" + ], "deployment": [ "id", "app_id", @@ -93,6 +101,15 @@ "series", "deploy_markers" ], + "signal_pages": [ + "app_id", + "slug", + "range", + "requested", + "visited", + "pageviews", + "uniques" + ], "signal_alert": [ "id", "app_id",