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
8 changes: 8 additions & 0 deletions lib/cleat/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule Cleat.CLI do
Logout,
Logs,
Servers,
Signals,
Status,
Whoami
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions lib/cleat/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
7 changes: 4 additions & 3 deletions lib/cleat/commands/completions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
143 changes: 143 additions & 0 deletions lib/cleat/commands/signals.ex
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions test/cleat/cli_mcp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions test/cleat/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]})
Expand Down
Loading
Loading