Files
Ilker ManapandClaude Opus 5.5 33e678ab84 Show a node's logs on the admin page
Seeing what a node did meant ssh through a jump host, to a machine whose
address nobody remembers, to read a journal only root may open. When an
engine install failed on a new node today, the reason was one line — and it
was four steps away from the page that said the install had failed.

Each node's row on the cluster page now links to its logs: the agent's own,
and every engine running there, chosen from the list the node itself sends.
The page can follow, refreshing every five seconds, and is on both the admin
listener and /admin. `llmgw ctl logs NODE [--source SHARD] [-n N]` reads the
same.

They travel over the stream the agent already holds, so they need nothing
the control plane does not already have: a request goes down, the lines come
back, and a reply that does not come in ten seconds is an agent too old to
answer. The agent keeps its last 2000 lines in memory as well as writing them
wherever it did before; the engines' last few hundred lines were already kept
and sent only when one crashed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:27:47 +02:00

61 lines
1.6 KiB
Go

package adminapi
import (
"context"
"net/http"
"strconv"
)
// LogsView is one of a node's logs, as the node sent it.
type LogsView struct {
Node string `json:"node"`
Source string `json:"source"`
Lines []string `json:"lines"`
// Sources are every log the node can show: "agent", and one per engine
// running there.
Sources []string `json:"sources"`
Error string `json:"error,omitempty"`
}
// maxLogLines bounds a request. The agent keeps two thousand lines of its own
// and a few hundred per engine, so more would only be refused further down.
const maxLogLines = 2000
// NodeLogs asks a node for the recent lines of one of its logs.
//
// Exported because the cluster page renders it server-side, the same as
// Nodes.
func (a *API) NodeLogs(ctx context.Context, node, source string, lines int) LogsView {
if source == "" {
source = "agent"
}
v := LogsView{Node: node, Source: source}
lr := a.ctl.LogReader()
if lr == nil {
v.Error = "this control plane cannot read node logs"
return v
}
if lines <= 0 {
lines = 300
}
out, sources, err := lr.Logs(ctx, node, source, min(lines, maxLogLines))
v.Lines, v.Sources = out, sources
if err != nil {
v.Error = err.Error()
}
if len(v.Sources) == 0 {
v.Sources = []string{"agent"}
}
return v
}
func (a *API) nodeLogs(w http.ResponseWriter, r *http.Request) {
lines, _ := strconv.Atoi(r.URL.Query().Get("lines"))
v := a.NodeLogs(r.Context(), r.PathValue("id"), r.URL.Query().Get("source"), lines)
code := http.StatusOK
if v.Error != "" && len(v.Lines) == 0 {
code = http.StatusBadGateway
}
writeJSON(w, code, v)
}