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>
69 lines
2.0 KiB
Go
69 lines
2.0 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
)
|
|
|
|
// LogSource reads a node's logs. Asked for by type assertion on the cluster
|
|
// source, so a source that cannot — a test's, an older one — leaves the page
|
|
// saying so rather than every Source having to grow the method.
|
|
type LogSource interface {
|
|
NodeLogs(ctx context.Context, node, source string, lines int) adminapi.LogsView
|
|
}
|
|
|
|
type nodeLogsData struct {
|
|
adminapi.LogsView
|
|
OnAdminListener bool
|
|
// Base is where this page lives, which differs between the admin
|
|
// listener and the client-facing port.
|
|
Base string
|
|
Back string
|
|
Count int
|
|
Counts []int
|
|
Follow bool
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// NodeLogsPage shows the recent lines of one of a node's logs: the agent's
|
|
// own, or an engine's output.
|
|
//
|
|
// What a node did used to be learned by ssh through a jump host and a journal
|
|
// only root may read — which, when an engine install fails at two in the
|
|
// morning, is several steps between the page that says it failed and the line
|
|
// that says why.
|
|
func NodeLogsPage(src Source, onAdminListener bool, version string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
node := r.PathValue("id")
|
|
d := nodeLogsData{
|
|
OnAdminListener: onAdminListener,
|
|
Base: "/admin/nodes/" + node + "/logs",
|
|
Back: "/admin",
|
|
Counts: []int{100, 300, 1000, 2000},
|
|
Follow: r.URL.Query().Get("follow") != "",
|
|
Version: version,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if onAdminListener {
|
|
d.Base, d.Back = "/nodes/"+node+"/logs", "/"
|
|
}
|
|
d.Count, _ = strconv.Atoi(r.URL.Query().Get("lines"))
|
|
if d.Count <= 0 {
|
|
d.Count = 300
|
|
}
|
|
ls, ok := src.(LogSource)
|
|
if !ok {
|
|
d.LogsView = adminapi.LogsView{Node: node, Error: "this control plane cannot read node logs"}
|
|
} else {
|
|
d.LogsView = ls.NodeLogs(r.Context(), node, r.URL.Query().Get("source"), d.Count)
|
|
}
|
|
noStore(w)
|
|
render(w, "node_logs", d)
|
|
}
|
|
}
|