From 60782a31f73fd7dbef236dccc934901ef4b6469d Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 1 Oct 2026 18:07:45 -0300 Subject: [PATCH 1/5] test: print signals health, metrics and ack alerts from the CLI --- test/cleat/cli_mcp_test.exs | 7 ++ test/cleat/client_test.exs | 14 +++ test/cleat/commands/signals_test.exs | 146 +++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 test/cleat/commands/signals_test.exs diff --git a/test/cleat/cli_mcp_test.exs b/test/cleat/cli_mcp_test.exs index 1b685e4..a55e885 100644 --- a/test/cleat/cli_mcp_test.exs +++ b/test/cleat/cli_mcp_test.exs @@ -16,4 +16,11 @@ defmodule Cleat.CLIMcpTest do [project_section | _] = String.split(usage, "Resources") assert project_section =~ ~r/^\s*mcp\s/m end + + test "usage documents signals health, metrics and alerts" do + usage = CLI.usage() + assert usage =~ "signals health" + assert usage =~ "signals metrics" + assert usage =~ "signals alerts" + end end diff --git a/test/cleat/client_test.exs b/test/cleat/client_test.exs index 3ebb47a..45779b4 100644 --- a/test/cleat/client_test.exs +++ b/test/cleat/client_test.exs @@ -28,6 +28,20 @@ defmodule Cleat.ClientTest do Client.search_logs(client, %{"app" => "my-app", "min_severity" => "err"}) end + test "signals_health GETs /api/v1/signals/health" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/health" + assert URI.decode_query(conn.query_string)["app"] == "catalogo" + Req.Test.json(conn, %{"data" => [%{"slug" => "catalogo", "status" => "healthy"}]}) + end) + + client = Client.new("https://panel.test", "tok") + + assert {:ok, %{"data" => [%{"slug" => "catalogo"}]}} = + Client.signals_health(client, %{"app" => "catalogo"}) + end + test "list_servers returns the panel payload" do Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, %{"data" => [%{"id" => 1, "name" => "srv"}]}) diff --git a/test/cleat/commands/signals_test.exs b/test/cleat/commands/signals_test.exs new file mode 100644 index 0000000..bc59566 --- /dev/null +++ b/test/cleat/commands/signals_test.exs @@ -0,0 +1,146 @@ +defmodule Cleat.Commands.SignalsTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias Cleat.Commands.Signals + + @conn %{panel: "https://panel.test", token: "tok"} + + setup do + Application.put_env(:cleat_cli, :req_plug, {Req.Test, __MODULE__}) + on_exit(fn -> Application.delete_env(:cleat_cli, :req_plug) end) + :ok + end + + test "health prints degraded apps and the preceding release" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/health" + + Req.Test.json(conn, %{ + "data" => [ + %{ + "app_id" => 6, + "slug" => "catalogo", + "name" => "Catálogo", + "status" => "degraded", + "reasons" => ["error_rate"], + "error_count" => 6, + "previous_error_count" => 1, + "preceding_release" => %{"id" => 12, "git_sha" => "bbb2222"} + } + ] + }) + end) + + output = capture_io(fn -> assert :ok = Signals.run(["health"], @conn) end) + assert output =~ "catalogo" + assert output =~ "degraded" + assert output =~ "error_rate" + assert output =~ "bbb2222" + end + + test "health passes an optional app filter" do + Req.Test.stub(__MODULE__, fn conn -> + params = URI.decode_query(conn.query_string) + assert params["app"] == "catalogo" + Req.Test.json(conn, %{"data" => []}) + end) + + capture_io(fn -> assert :ok = Signals.run(["health", "catalogo"], @conn) end) + end + + test "metrics requires an app and forwards --range" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/signals/metrics" + params = URI.decode_query(conn.query_string) + assert params["app"] == "catalogo" + assert params["range"] == "6h" + + Req.Test.json(conn, %{ + "data" => %{ + "app_id" => 6, + "slug" => "catalogo", + "range" => "6h", + "red" => %{"errors" => 3, "logs" => 4, "error_rate" => 0.75, "latency_ms" => nil}, + "host" => %{"cpu" => nil, "memory" => nil, "disk" => nil, "restarts" => 0}, + "series" => [], + "deploy_markers" => [%{"git_sha" => "cafebabe"}] + } + }) + end) + + output = + capture_io(fn -> + assert :ok = Signals.run(["metrics", "catalogo"], Map.put(@conn, :range, "6h")) + end) + + assert output =~ "catalogo" + assert output =~ "3" + assert output =~ "cafebabe" + end + + test "metrics without an app returns usage" do + assert {:error, message} = Signals.run(["metrics"], @conn) + assert message =~ "usage" + end + + test "alerts lists firing rows" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/alerts" + + Req.Test.json(conn, %{ + "data" => [ + %{ + "id" => 9, + "app_id" => 6, + "slug" => "catalogo", + "rule" => "error_rate", + "status" => "firing", + "message" => "Taxa de erro subiu em catalogo", + "channel" => "in_app" + } + ] + }) + end) + + output = capture_io(fn -> assert :ok = Signals.run(["alerts"], @conn) end) + assert output =~ "catalogo" + assert output =~ "error_rate" + assert output =~ "Taxa de erro subiu em catalogo" + end + + test "alerts ack posts to the panel" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/v1/signals/alerts/9/ack" + + Req.Test.json(conn, %{ + "data" => %{"id" => 9, "status" => "acked", "slug" => "catalogo", "rule" => "error_rate"} + }) + end) + + output = capture_io(fn -> assert :ok = Signals.run(["alerts", "ack", "9"], @conn) end) + assert output =~ "acked" or output =~ "9" + end + + test "json mode prints the raw health payload" do + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, %{"data" => [%{"slug" => "catalogo", "status" => "healthy"}]}) + end) + + output = + capture_io(fn -> + assert :ok = Signals.run(["health"], Map.put(@conn, :json, true)) + end) + + assert output =~ ~s("slug": "catalogo") + end + + test "unknown subcommand returns usage" do + assert {:error, message} = Signals.run(["nope"], @conn) + assert message =~ "usage" + end +end From 662b37ce4fe0f76be2f39ef98bcd9ef3caf65de8 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 1 Oct 2026 18:07:45 -0300 Subject: [PATCH 2/5] feat: add cleat signals health, metrics and alerts --- lib/cleat/cli.ex | 8 ++ lib/cleat/client.ex | 18 ++++ lib/cleat/commands/completions.ex | 7 +- lib/cleat/commands/signals.ex | 143 ++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 lib/cleat/commands/signals.ex diff --git a/lib/cleat/cli.ex b/lib/cleat/cli.ex index f9216e7..d39388b 100644 --- a/lib/cleat/cli.ex +++ b/lib/cleat/cli.ex @@ -17,6 +17,7 @@ defmodule Cleat.CLI do Logout, Logs, Servers, + Signals, Status, Whoami } @@ -74,6 +75,7 @@ defmodule Cleat.CLI do release: :string, environment: :string, group: :boolean, + range: :string, release_name: :string, systemd_unit: :string, release_path: :string, @@ -124,6 +126,7 @@ defmodule Cleat.CLI do defp dispatch(["apps" | rest], opts), do: run(Apps.run(rest, opts)) defp dispatch(["env" | rest], opts), do: run(Env.run(rest, opts)) defp dispatch(["events" | rest], opts), do: run(Events.run(rest, opts)) + defp dispatch(["signals" | rest], opts), do: run(Signals.run(rest, opts)) defp dispatch(["deploy" | rest], opts), do: run(Deploy.run(List.first(rest), opts)) defp dispatch(["drop" | rest], opts), do: run(Drop.run(rest, opts)) @@ -215,6 +218,11 @@ defmodule Cleat.CLI do Search collected log events (levels: emerg alert crit err warning notice info debug) + signals health [APP] Overview of app health + signals metrics APP [--range 1h|6h|24h|1d] + RED metrics and deploy markers + signals alerts [list] Open default alerts + signals alerts ack ID Confirm a firing alert Environment env list APP [--branch B] [--reveal] List env vars (secrets masked) diff --git a/lib/cleat/client.ex b/lib/cleat/client.ex index 290522d..c6992c7 100644 --- a/lib/cleat/client.ex +++ b/lib/cleat/client.ex @@ -83,6 +83,24 @@ defmodule Cleat.Client do request(client, :get, "/api/v1/logs/groups", params: params) end + @doc "Tenant health overview (`GET /api/v1/signals/health`)." + def signals_health(%__MODULE__{} = client, params \\ %{}) when is_map(params) do + request(client, :get, "/api/v1/signals/health", params: params) + end + + @doc "RED + host snapshot for one app (`GET /api/v1/signals/metrics`)." + def signals_metrics(%__MODULE__{} = client, params) when is_map(params) do + request(client, :get, "/api/v1/signals/metrics", 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") + + @doc "Ack a firing alert (`POST /api/v1/signals/alerts/:id/ack`)." + def ack_signal_alert(%__MODULE__{} = client, id) when is_binary(id) or is_integer(id) do + request(client, :post, "/api/v1/signals/alerts/#{id}/ack", json: %{}) + end + defp log_params(opts) do %{ "tail" => opts[:tail], diff --git a/lib/cleat/commands/completions.ex b/lib/cleat/commands/completions.ex index 66669f4..310b663 100644 --- a/lib/cleat/commands/completions.ex +++ b/lib/cleat/commands/completions.ex @@ -4,15 +4,16 @@ defmodule Cleat.Commands.Completions do @usage "usage: cleat completions bash|zsh" @commands ~w( - login logout whoami init config servers apps env deploy drop cancel status - logs completions mcp version help + login logout whoami init config servers apps env events signals deploy drop + cancel status logs completions mcp version help ) @subcommands %{ "servers" => ~w(list show create sync delete logs), "apps" => ~w(list show create update delete logs), "env" => ~w(list set unset), - "config" => ~w(list get set unset) + "config" => ~w(list get set unset), + "signals" => ~w(health metrics alerts) } def run(["bash"], _opts), do: print(bash()) diff --git a/lib/cleat/commands/signals.ex b/lib/cleat/commands/signals.ex new file mode 100644 index 0000000..3f789b0 --- /dev/null +++ b/lib/cleat/commands/signals.ex @@ -0,0 +1,143 @@ +defmodule Cleat.Commands.Signals do + @moduledoc """ + `cleat signals` — health, metrics and alerts from the panel (Corte 02). + """ + + alias Cleat.{Client, Commands, Output} + + @usage """ + usage: + cleat signals health [APP] + cleat signals metrics APP [--range 1h|6h|24h|1d] + cleat signals alerts [list] + cleat signals alerts ack ID + """ + + 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(["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) + def run(_args, _opts), do: {:error, @usage} + + defp health(args, opts) do + with {:ok, app} <- optional_app(args, opts), + {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.signals_health(client, compact(%{"app" => app})) do + rows = Commands.data(body) + if opts[:json], do: Output.json(rows), else: print_health(rows) + :ok + end + end + + defp metrics(app, opts) do + params = compact(%{"app" => app, "range" => opts[:range]}) + + with {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.signals_metrics(client, params) do + data = Commands.data(body) + if opts[:json], do: Output.json(data), else: print_metrics(data) + :ok + end + end + + defp alerts(opts) do + with {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.signals_alerts(client) do + rows = Commands.data(body) + if opts[:json], do: Output.json(rows), else: print_alerts(rows) + :ok + end + end + + defp ack(id, opts) do + with {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.ack_signal_alert(client, id) do + alert = Commands.data(body) + + if opts[:json] do + Output.json(alert) + else + Output.success("Acked alert #{alert["id"] || id} on #{alert["slug"] || "app"}") + end + + :ok + end + end + + defp optional_app([], opts), do: {:ok, opts[:app]} + defp optional_app([app], _opts) when is_binary(app), do: {:ok, app} + defp optional_app(_args, _opts), do: {:error, @usage} + + defp compact(params) do + params + |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) + |> Map.new() + end + + defp print_health([]) do + Output.info("No applications matched.") + end + + defp print_health(rows) do + table = + Enum.map(rows, fn row -> + [ + row["slug"], + row["status"], + reasons(row["reasons"]), + release(row["preceding_release"]), + row["error_count"] + ] + end) + + Output.table(table, ["APP", "STATUS", "REASONS", "RELEASE", "ERRORS"]) + end + + defp print_metrics(data) do + red = data["red"] || %{} + host = data["host"] || %{} + markers = data["deploy_markers"] || [] + + Output.info("#{data["slug"]} range=#{data["range"]}") + + Output.info( + "RED errors=#{red["errors"]} logs=#{red["logs"]} error_rate=#{red["error_rate"]} latency=#{dash(red["latency_ms"])}" + ) + + Output.info( + "HOST cpu=#{dash(host["cpu"])} memory=#{dash(host["memory"])} disk=#{dash(host["disk"])} restarts=#{host["restarts"]}" + ) + + Output.info("MARKERS #{markers_label(markers)}") + end + + defp print_alerts([]) do + Output.info("No open alerts.") + end + + defp print_alerts(rows) do + table = + Enum.map(rows, fn row -> + [row["id"], row["slug"], row["rule"], row["status"], row["message"]] + end) + + Output.table(table, ["ID", "APP", "RULE", "STATUS", "MESSAGE"]) + end + + defp reasons(list) when is_list(list) and list != [], do: Enum.join(list, ",") + defp reasons(_), do: "—" + + defp release(%{"git_sha" => sha}) when is_binary(sha), do: String.slice(sha, 0, 8) + defp release(_), do: "—" + + defp dash(nil), do: "—" + defp dash(value), do: value + + defp markers_label([]), do: "—" + + defp markers_label(markers) do + Enum.map_join(markers, ",", fn marker -> marker["git_sha"] || inspect(marker) end) + end +end From 550b4fa7048f86b5e6748f7868687340d41ead4a Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 1 Oct 2026 18:07:45 -0300 Subject: [PATCH 3/5] test: MCP signals_health, signals_metrics and signals_alerts --- test/cleat/mcp/tools_test.exs | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/cleat/mcp/tools_test.exs b/test/cleat/mcp/tools_test.exs index a826a2d..2e8c586 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -33,6 +33,9 @@ defmodule Cleat.MCP.ToolsTest do "servers_list", "server_logs", "logs_search", + "signals_health", + "signals_metrics", + "signals_alerts", "apps_list", "apps_show", "apps_create", @@ -164,6 +167,47 @@ defmodule Cleat.MCP.ToolsTest do assert [%{"message" => "boom"}] = Jason.decode!(text) end + test "signals_health GETs /api/v1/signals/health" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/health" + assert URI.decode_query(conn.query_string)["app"] == "catalogo" + + Req.Test.json(conn, %{"data" => [%{"slug" => "catalogo", "status" => "degraded"}]}) + end) + + assert {:ok, text} = + Tools.call("signals_health", %{ + "app" => "catalogo", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert [%{"slug" => "catalogo", "status" => "degraded"}] = Jason.decode!(text) + end + + test "signals_metrics requires an app" do + assert {:error, message} = Tools.call("signals_metrics", %{}) + assert message =~ "app" + end + + test "signals_alerts acks when id is present" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/v1/signals/alerts/9/ack" + Req.Test.json(conn, %{"data" => %{"id" => 9, "status" => "acked"}}) + end) + + assert {:ok, text} = + Tools.call("signals_alerts", %{ + "id" => "9", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert %{"status" => "acked"} = Jason.decode!(text) + end + test "server_logs GETs /api/v1/servers/5/logs with the unit filter" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "GET" From 43b56890c2dfd683f447517b2b9908d124a99ce1 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 1 Oct 2026 18:07:46 -0300 Subject: [PATCH 4/5] feat: expose Corte 02 signals tools on MCP --- lib/cleat/mcp/tools.ex | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index 2ba9ec2..1b8c2e0 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -85,6 +85,12 @@ defmodule Cleat.MCP.Tools do defp data_text(body), do: json(Commands.data(body)) + defp compact_params(params) do + params + |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end) + |> Map.new() + end + defp drop_plan(%{"app" => app}) when is_binary(app) and app != "" do {:ok, {:app, app}} end @@ -386,6 +392,72 @@ defmodule Cleat.MCP.Tools do end) end }, + %{ + "name" => "signals_health", + "description" => + "Overview of application health from collected logs (degraded/down, reasons, preceding release). Optional app filter.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "app" => @app, + "panel" => @panel, + "token" => @token + } + }, + "handler" => fn args -> + params = compact_params(%{"app" => args["app"]}) + + with_client(args, fn client -> + with {:ok, body} <- Client.signals_health(client, params), do: {:ok, data_text(body)} + end) + end + }, + %{ + "name" => "signals_metrics", + "description" => + "RED metrics, host snapshot and deploy markers for one app. 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_metrics(client, params), do: {:ok, data_text(body)} + end) + end + }, + %{ + "name" => "signals_alerts", + "description" => "List open default alerts. Pass id to ack a firing alert.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "id" => %{"type" => "string", "description" => "Alert id to ack"}, + "panel" => @panel, + "token" => @token + } + }, + "handler" => fn args -> + with_client(args, fn client -> + result = + case args["id"] do + id when is_binary(id) and id != "" -> Client.ack_signal_alert(client, id) + id when is_integer(id) -> Client.ack_signal_alert(client, id) + _ -> Client.signals_alerts(client) + end + + with {:ok, body} <- result, do: {:ok, data_text(body)} + end) + end + }, %{ "name" => "apps_list", "description" => "List apps", From b319c751623daffb9ce2a7a0015cd0aa25fd9a82 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 1 Oct 2026 18:07:46 -0300 Subject: [PATCH 5/5] test: vendor panel contract keys for signal resources --- test/cleat/contract_test.exs | 6 ++++++ test/fixtures/api_contract.json | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/test/cleat/contract_test.exs b/test/cleat/contract_test.exs index cd9f3a9..c5f93e4 100644 --- a/test/cleat/contract_test.exs +++ b/test/cleat/contract_test.exs @@ -26,6 +26,12 @@ defmodule Cleat.ContractTest do "log_event" => ~w(id app_id server_id deployment_id unit source severity message environment fingerprint occurred_at), "log_group" => ~w(fingerprint severity count sample last_seen_at), + "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_alert" => + ~w(id app_id slug rule status message channel fired_at acked_at delivered_at), + "signal_incident" => ~w(app_id slug events), "user" => ~w(id email), "tenant" => ~w(id name slug), "me" => ~w(user tenant role), diff --git a/test/fixtures/api_contract.json b/test/fixtures/api_contract.json index d728e47..d0f311f 100644 --- a/test/fixtures/api_contract.json +++ b/test/fixtures/api_contract.json @@ -74,6 +74,42 @@ "sample", "last_seen_at" ], + "signal_health": [ + "app_id", + "slug", + "name", + "status", + "reasons", + "error_count", + "previous_error_count", + "preceding_release" + ], + "signal_metrics": [ + "app_id", + "slug", + "range", + "red", + "host", + "series", + "deploy_markers" + ], + "signal_alert": [ + "id", + "app_id", + "slug", + "rule", + "status", + "message", + "channel", + "fired_at", + "acked_at", + "delivered_at" + ], + "signal_incident": [ + "app_id", + "slug", + "events" + ], "me": [ "user", "tenant",