Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/cleat/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ defmodule Cleat.CLI do
grep: :string,
unit: :string,
query: :string,
sql: :string,
severity: :string,
min_severity: :string,
until: :string,
Expand Down Expand Up @@ -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)

Expand All @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions lib/cleat/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
45 changes: 44 additions & 1 deletion lib/cleat/commands/apps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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…")

Expand Down
4 changes: 2 additions & 2 deletions lib/cleat/commands/completions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
41 changes: 41 additions & 0 deletions lib/cleat/commands/signals.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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" =>
Expand Down
2 changes: 2 additions & 0 deletions test/cleat/cli_mcp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions test/cleat/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions test/cleat/commands/apps_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions test/cleat/commands/signals_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading