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
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ panel and is stored as a hash server-side.
| `cleat apps update APP` | Edit repo / branch / auto-deploy / host / port / runtime |
| `cleat apps logs APP` | App journal, with `--tail`, `--since`, `--grep`, `--follow` |
| `cleat servers logs ID` | Host journal, with `--unit`, `--tail`, `--since`, `--grep`, `--follow` |
| `cleat events [APP]` | Search collected log events, with `--server`, `--unit`, `--query`, `--severity`, `--min-severity`, `--since`, `--until`, `--limit` |
| `cleat env list APP` | List env vars (secrets masked) |
| `cleat env set APP K=V` | Upsert one or more env vars |
| `cleat env unset APP KEY` | Delete an env var |
Expand All @@ -87,9 +88,10 @@ codex mcp add cleat -- cleat mcp
grok mcp add cleat -- cleat mcp
```

Tools: `whoami`, `servers_list`, `apps_list`, `apps_show`, `apps_create`,
`apps_update`, `apps_logs`, `env_list`, `env_set`, `env_unset`, `deploy`,
`deploy_status`, `deploy_logs`, `cancel_deploy`, `drop`, `init_project`.
Tools: `whoami`, `servers_list`, `server_logs`, `logs_search`, `apps_list`,
`apps_show`, `apps_create`, `apps_update`, `apps_logs`, `env_list`, `env_set`,
`env_unset`, `deploy`, `deploy_status`, `deploy_logs`, `cancel_deploy`, `drop`,
`init_project`.

`deploy` queues and returns a `deployment_id`; follow it with `deploy_status`
and `deploy_logs`. Credentials come from `cleat login` (or `CLEAT_PANEL_URL` /
Expand Down Expand Up @@ -205,6 +207,32 @@ cleat servers logs 5 --unit caddy --since 30m
`2026-09-21 14:30`). `--grep` is a case-sensitive substring filter. `--tail`
defaults to 200 (max 5000). `--follow` keeps polling and prints new lines.

### Collected log events

`apps logs` and `servers logs` read the live journal. `cleat events` searches
the panel's collected log store instead: events are enriched with tenant, app,
deploy, server and unit by the panel's collector, so they can be filtered by
app, severity and time window without touching the VM.

```bash
# errors and worse for one app in the last hour
cleat events my-app --min-severity err --since 1h

# free-text search across the tenant, newest first
cleat events --query "timeout" --limit 50

# exact level, specific unit, machine-readable
cleat events --app landing --severity warning --unit caddy --json

# filter by release sha and cluster similar errors
cleat events my-app --release abc123 --group
```

Levels: `emerg`, `alert`, `crit`, `err`, `warning`, `notice`, `info`, `debug`.
`--min-severity` includes that level and above. The collector is opt-in on the
panel (`LOG_COLLECTOR_ENABLED=true`); if it is off, `cleat events` returns no
rows.

### Environment variables

Env vars are stored encrypted by the panel and written to the server
Expand Down
22 changes: 21 additions & 1 deletion lib/cleat/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Cleat.CLI do
Deploy,
Drop,
Env,
Events,
Init,
Login,
Logout,
Expand Down Expand Up @@ -65,6 +66,14 @@ defmodule Cleat.CLI do
since: :string,
grep: :string,
unit: :string,
query: :string,
severity: :string,
min_severity: :string,
until: :string,
limit: :integer,
release: :string,
environment: :string,
group: :boolean,
release_name: :string,
systemd_unit: :string,
release_path: :string,
Expand All @@ -81,7 +90,7 @@ defmodule Cleat.CLI do
version: :boolean
]

@aliases [p: :panel, h: :help, v: :version]
@aliases [p: :panel, h: :help, v: :version, q: :query]

def main(argv) do
{opts, args, invalid} = OptionParser.parse(argv, strict: @switches, aliases: @aliases)
Expand Down Expand Up @@ -114,6 +123,7 @@ defmodule Cleat.CLI do
defp dispatch(["servers" | rest], opts), do: run(Servers.run(rest, opts))
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(["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 @@ -196,6 +206,16 @@ defmodule Cleat.CLI do
servers logs ID [--unit U] [--tail N] [--since S] [--grep T] [--follow]
Host journal (all units, or one with --unit)

Observability
events [APP] [--server ID] [--unit U] \\
[--query TEXT] [-q TEXT] \\
[--severity LEVEL] [--min-severity LEVEL] \\
[--since S] [--until S] [--limit N] \\
[--release SHA] [--environment B] [--group]
Search collected log events
(levels: emerg alert crit err
warning notice info debug)

Environment
env list APP [--branch B] [--reveal] List env vars (secrets masked)
env set APP K=V [K=V ...] \\
Expand Down
18 changes: 18 additions & 0 deletions lib/cleat/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ defmodule Cleat.Client do
request(client, :get, "/api/v1/servers/#{id}/logs", params: log_params(opts))
end

@doc """
Searches collected log events (`GET /api/v1/logs`).

`params` is a string-keyed map with the filters the panel understands:
`app`, `server`, `unit`, `q`, `severity`, `min_severity`, `since`, `until`
and `limit`.
"""
def search_logs(%__MODULE__{} = client, params) when is_map(params) do
request(client, :get, "/api/v1/logs", params: params)
end

@doc """
Groups similar collected errors (`GET /api/v1/logs/groups`).
"""
def search_log_groups(%__MODULE__{} = client, params) when is_map(params) do
request(client, :get, "/api/v1/logs/groups", params: params)
end

defp log_params(opts) do
%{
"tail" => opts[:tail],
Expand Down
121 changes: 121 additions & 0 deletions lib/cleat/commands/events.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
defmodule Cleat.Commands.Events do
@moduledoc """
`cleat events` — searches the panel's collected log events.

This is the stored, correlatable view of logs (observability Corte 01):
events are enriched by the panel with tenant, app, server, deployment and
unit, so they can be filtered by app, severity, unit and time window without
touching the VM.
"""

alias Cleat.{Client, Commands, Output}

@usage "usage: cleat events [APP] [--server ID] [--unit U] [--query TEXT] [--severity LEVEL] [--min-severity LEVEL] [--since S] [--until S] [--limit N] [--release SHA] [--environment B] [--group] [--json]"

@severities ~w(emerg alert crit err warning notice info debug)

def run(args, opts) do
with {:ok, params} <- params(args, opts),
{:ok, client} <- Commands.client(opts),
{:ok, body} <- fetch(client, params, opts) do
rows = Commands.data(body)

cond do
opts[:json] -> Output.json(rows)
opts[:group] -> print_groups(rows)
true -> print(rows)
end

:ok
end
end

defp params(args, opts) do
with {:ok, app} <- app(args, opts),
{:ok, severity} <- severity(opts[:severity], "--severity"),
{:ok, min_severity} <- severity(opts[:min_severity], "--min-severity") do
params = %{
"app" => app,
"server" => opts[:server],
"unit" => opts[:unit],
"q" => opts[:query],
"severity" => severity,
"min_severity" => min_severity,
"since" => opts[:since],
"until" => opts[:until],
"limit" => opts[:limit],
"release" => opts[:release],
"environment" => opts[:environment]
}

{:ok, params |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) |> Map.new()}
end
end

defp app([], opts), do: {:ok, opts[:app]}
defp app([app], _opts) when is_binary(app), do: {:ok, app}
defp app(_args, _opts), do: {:error, @usage}

defp severity(value, _flag) when value in [nil, ""], do: {:ok, nil}

defp severity(value, flag) do
if value in @severities do
{:ok, value}
else
{:error, "invalid #{flag} (use #{Enum.join(@severities, ", ")})"}
end
end

defp fetch(client, params, opts) do
if opts[:group] do
Client.search_log_groups(client, params)
else
Client.search_logs(client, params)
end
end

defp print([]), do: Output.info("No log events matched.")

defp print(events) do
rows =
Enum.map(events, fn event ->
[
timestamp(event["occurred_at"]),
event["severity"],
event["app_id"] || "—",
event["unit"] || "—",
message(event["message"])
]
end)

Output.table(rows, ["TIME", "SEV", "APP", "UNIT", "MESSAGE"])
end

defp print_groups([]), do: Output.info("No error groups matched.")

defp print_groups(groups) do
rows =
Enum.map(groups, fn group ->
[
group["count"],
group["severity"],
group["last_seen_at"] || "—",
message(group["sample"])
]
end)

Output.table(rows, ["COUNT", "SEV", "LAST", "SAMPLE"])
end

defp timestamp(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> Output.datetime(datetime)
_ -> value
end
end

defp timestamp(_), do: "—"

defp message(nil), do: ""
defp message(text), do: text |> String.replace("\n", " ") |> String.slice(0, 120)
end
59 changes: 59 additions & 0 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,65 @@ defmodule Cleat.MCP.Tools do
end)
end
},
%{
"name" => "logs_search",
"description" =>
"Search collected log events by app, server, unit, text, severity, release, environment and time window. Set group=true for clustered errors.",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"app" => @app,
"server" => %{"type" => "string", "description" => "Server id"},
"unit" => %{"type" => "string"},
"q" => %{"type" => "string", "description" => "Case-insensitive substring"},
"severity" => %{
"type" => "string",
"description" => "Exact level: emerg alert crit err warning notice info debug"
},
"min_severity" => %{
"type" => "string",
"description" => "Include this level and above"
},
"release" => %{"type" => "string", "description" => "Deployment id or git sha prefix"},
"environment" => %{"type" => "string", "description" => "App branch / environment"},
"group" => %{"type" => "boolean", "description" => "Cluster similar errors"},
"since" => %{"type" => "string", "description" => "30m, 2h, 1d or ISO time"},
"until" => %{"type" => "string", "description" => "ISO time"},
"limit" => %{"type" => "integer"},
"panel" => @panel,
"token" => @token
}
},
"handler" => fn args ->
params =
%{
"app" => args["app"],
"server" => args["server"],
"unit" => args["unit"],
"q" => args["q"],
"severity" => args["severity"],
"min_severity" => args["min_severity"],
"release" => args["release"],
"environment" => args["environment"],
"since" => args["since"],
"until" => args["until"],
"limit" => args["limit"]
}
|> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end)
|> Map.new()

with_client(args, fn client ->
result =
if args["group"] do
Client.search_log_groups(client, params)
else
Client.search_logs(client, params)
end

with {:ok, body} <- result, do: {:ok, data_text(body)}
end)
end
},
%{
"name" => "apps_list",
"description" => "List apps",
Expand Down
19 changes: 19 additions & 0 deletions test/cleat/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ defmodule Cleat.ClientTest do
:ok
end

test "search_logs sends the filters to /api/v1/logs" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "GET"
assert conn.request_path == "/api/v1/logs"

assert URI.decode_query(conn.query_string) == %{
"app" => "my-app",
"min_severity" => "err"
}

Req.Test.json(conn, %{"data" => [%{"id" => 1, "message" => "boom"}]})
end)

client = Client.new("https://panel.test", "tok")

assert {:ok, %{"data" => [%{"id" => 1, "message" => "boom"}]}} =
Client.search_logs(client, %{"app" => "my-app", "min_severity" => "err"})
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