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>
257 lines
11 KiB
HTML
257 lines
11 KiB
HTML
{{define "dashboard"}}<!doctype html>
|
||
<html lang="en"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta http-equiv="refresh" content="{{.RefreshSec}}">
|
||
<title>llmgw — cluster</title><link rel="stylesheet" href="/_static/base.css">
|
||
</head><body><div class="wrap">
|
||
<header>
|
||
<h1>Cluster</h1>
|
||
<p class="sub">{{.NodeCount}} nodes · {{.ReadyReplicas}} of {{.TotalReplicas}} replicas ready · {{.ModelCount}} models</p>
|
||
<nav>
|
||
{{if .OnAdminListener}}
|
||
<a href="/metrics">metrics</a>
|
||
<a href="/debug/pprof/">pprof</a>
|
||
<a href="/api/v1/nodes">api</a>
|
||
{{else}}
|
||
<a href="/admin/users">accounts</a>
|
||
<a href="/admin/usage">usage</a>
|
||
{{if .Nav.Mirror}} <a href="/admin/mirror">mirror</a>
|
||
{{end}}
|
||
<a href="/profile">your keys</a>
|
||
<a href="/chat">chat</a>
|
||
{{end}}
|
||
</nav>
|
||
</header>
|
||
|
||
{{if .HasCharts}}
|
||
<div class="tiles">
|
||
<div class="tile"><div class="v">{{.Requests24h}}</div><div class="k">requests · 24h</div>{{.RequestSpark}}</div>
|
||
<div class="tile"><div class="v">{{compact .Tokens24h}}</div><div class="k">tokens · 24h</div>{{.TokenSpark}}</div>
|
||
<div class="tile"><div class="v">{{.Errors24h}}</div><div class="k">errors · 24h</div></div>
|
||
<div class="tile"><div class="v">{{units .Spend24h}}</div><div class="k">spend · 24h</div></div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<h2>Capacity</h2>
|
||
{{if .Summary.GPUs}}
|
||
<table><tr><th>Class</th><th class="num">In use</th><th class="num">Free</th><th class="num">Faulty</th><th>Usage</th></tr>
|
||
{{range .Summary.GPUs}}<tr>
|
||
<td><code>{{.Class}}</code></td>
|
||
<td class="num">{{.Used}}/{{.Total}}</td>
|
||
<td class="num">{{.Free}}</td>
|
||
<td class="num">{{if .Unhealthy}}<span class="bad">{{.Unhealthy}}</span>{{else}}—{{end}}</td>
|
||
<td class="meter"><span style="width:{{pct .Used .Total}}%"></span><em>{{pct .Used .Total}}%</em></td>
|
||
</tr>{{end}}</table>
|
||
{{else}}<p class="empty">No GPUs have been reported.</p>{{end}}
|
||
|
||
{{if .Summary.Fabrics}}
|
||
<h2>Fabrics</h2>
|
||
<table><tr><th>Fabric</th><th class="num">Nodes</th><th class="num">Fully free</th><th>Weights</th></tr>
|
||
{{range .Summary.Fabrics}}<tr>
|
||
<td><code>{{.Fabric}}</code></td>
|
||
<td class="num">{{.Nodes}}</td>
|
||
<td class="num">{{.LargestFreeBlock}}</td>
|
||
<td>{{if .NodesWithWeights}}{{.Models}} model(s) on {{.NodesWithWeights}}/{{.Nodes}} nodes{{else}}<span class="note">not reported</span>{{end}}</td>
|
||
</tr>{{end}}</table>
|
||
<p class="note">Fully free counts whole unused nodes, which is what decides whether a
|
||
replica spanning nodes can land: free cards on the wrong fabric are not capacity
|
||
for a model that needs this one.</p>
|
||
{{end}}
|
||
|
||
{{if .Summary.IdleReplicas}}
|
||
<h2>Holding hardware nothing is asking for</h2>
|
||
<table><tr><th>Replica</th><th>Model</th><th class="num">GPUs</th><th class="num">Unused for</th></tr>
|
||
{{range .Summary.IdleReplicas}}<tr>
|
||
<td><code>{{.ID}}</code></td>
|
||
<td>{{.Model}}</td>
|
||
<td class="num">{{.GPUs}}</td>
|
||
<td class="num">{{duration .IdleSec}}</td>
|
||
</tr>{{end}}</table>
|
||
{{end}}
|
||
|
||
{{if .HasPeople}}
|
||
<h2>People</h2>
|
||
{{.UserChart}}
|
||
<table><tr><th>Person</th><th class="num">Keys</th><th class="num">Requests</th><th class="num">Tokens</th><th class="num">Errors</th><th class="num">Spend</th><th>Last seen</th></tr>
|
||
{{range .Users}}<tr>
|
||
<td>{{if .Email}}{{.Email}}{{else}}<span class="note">operator-issued keys</span>{{end}}</td>
|
||
<td class="num">{{.Keys}}</td>
|
||
<td class="num">{{.Requests}}</td>
|
||
<td class="num">{{compact .Tokens}}</td>
|
||
<td class="num">{{if .Errors}}<span class="bad">{{.Errors}}</span>{{else}}—{{end}}</td>
|
||
<td class="num">{{units .CostMicros}}</td>
|
||
<td>{{ago .LastSeen}}</td>
|
||
</tr>{{end}}</table>
|
||
{{end}}
|
||
|
||
{{if .Models2}}
|
||
<h2>What is being run</h2>
|
||
{{.ModelChart}}
|
||
<table><tr><th>Model</th><th class="num">Requests</th><th class="num">Tokens</th><th class="num">Errors</th><th class="num">Mean latency</th></tr>
|
||
{{range .Models2}}<tr>
|
||
<td><code>{{.Model}}</code></td>
|
||
<td class="num">{{.Requests}}</td>
|
||
<td class="num">{{compact .Tokens}}</td>
|
||
<td class="num">{{if .Errors}}<span class="bad">{{.Errors}}</span>{{else}}—{{end}}</td>
|
||
<td class="num">{{printf "%.0f" .AvgLatency}} ms</td>
|
||
</tr>{{end}}</table>
|
||
<p class="note">Counted by the model that actually answered, which differs from the one
|
||
requested whenever a fallback served it.</p>
|
||
{{end}}
|
||
|
||
<h2>Nodes</h2>
|
||
{{if .Nodes}}
|
||
<table><tr><th>Node</th><th>State</th><th class="num">GPUs</th><th class="num">RAM</th><th class="num">Disk</th><th>Engines</th><th>Classes</th><th>Fabrics</th><th class="num">Models</th><th class="num">Replicas</th><th>Last seen</th></tr>
|
||
{{range .Nodes}}<tr>
|
||
<td><code>{{.ID}}</code>{{if .MgmtAddr}} <span class="note">{{.MgmtAddr}}</span>{{end}}
|
||
<a class="note" href="{{if $.OnAdminListener}}/nodes/{{.ID}}/logs{{else}}/admin/nodes/{{.ID}}/logs{{end}}">logs</a></td>
|
||
<td class="state state-{{.State}}">{{.State}}</td>
|
||
<td class="num">{{.GPUFree}}/{{.GPUTotal}}</td>
|
||
<td class="num">{{if .MemoryGB}}{{.MemoryGB}}G{{else}}—{{end}}</td>
|
||
{{/* Free out of total, because the first is meaningless without the
|
||
second, and an em dash where nothing was reported: an agent that
|
||
was never asked about its disk must not read as a full one. */}}
|
||
<td class="num">{{if .DiskTotalGB}}{{.DiskFreeGB}}G/{{.DiskTotalGB}}G{{else}}—{{end}}</td>
|
||
<td>{{if .Engines}}{{join .Engines}}{{else}}<span class="note">none yet</span>{{end}}</td>
|
||
<td>{{join .Classes}}</td>
|
||
<td>{{join .Fabrics}}</td>
|
||
<td class="num">{{if .Models}}{{len .Models}}{{else}}—{{end}}</td>
|
||
<td class="num">{{.Replicas}}</td>
|
||
<td>{{or .LastSeenAgo "—"}}</td>
|
||
</tr>{{if .Unhealthy}}<tr><td colspan="11" class="note bad">{{join .Unhealthy}}</td></tr>{{end}}
|
||
{{range .Provision}}{{if ne .State "ready"}}<tr><td colspan="11" class="note{{if or (eq .State "failed") (eq .State "unsupported")}} bad{{end}}">{{.Engine}} {{.Version}}{{if .Variant}} ({{.Variant}}){{end}} {{.State}}: {{.Detail}}</td></tr>{{end}}{{end}}
|
||
{{range .Warnings}}<tr><td colspan="11" class="note bad">{{.}}</td></tr>{{end}}
|
||
{{end}}</table>
|
||
{{else}}<p class="empty">No agents have registered.</p>{{end}}
|
||
|
||
<h2>Models</h2>
|
||
{{with .Fleet}}
|
||
<h2>What the cluster is doing <span class="opt">last {{.Window}}</span></h2>
|
||
{{if .Err}}
|
||
{{/* Said outright. An empty panel reads as a healthy cluster with nothing
|
||
happening, which is the opposite of not knowing. */}}
|
||
<p class="note error-row">No history: {{.Err}}</p>
|
||
{{else}}
|
||
<div class="chart-row">
|
||
{{template "tschart" dict "t" .Saturation "title" "Pool saturation, by model"
|
||
"note" "The worse of queue depth and cache pressure. 1 is the limit measured as healthy, not the point of failure."}}
|
||
{{template "tschart" dict "t" .Ready "title" "Replicas ready, by model"
|
||
"note" "A model deliberately stopped has no line at all, which is why an on-demand catalogue looks quiet here."}}
|
||
</div>
|
||
<div class="chart-row">
|
||
{{template "tschart" dict "t" .Queue "title" "Engine queue depth, by replica"
|
||
"note" "Requests accepted and not yet started. The first thing to move when a replica is falling behind."}}
|
||
{{template "tschart" dict "t" .KV "title" "KV cache occupancy, by replica"
|
||
"note" "How full the attention cache is. Long contexts fill it with an empty queue, which is why saturation reads both."}}
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
|
||
{{if .Models}}
|
||
<table><tr><th>Model</th><th>Aliases</th><th class="num">Ready</th><th class="num">Wanted</th><th>Profiles</th><th>Fallbacks</th></tr>
|
||
{{range .Models}}<tr>
|
||
<td><code>{{.Name}}</code></td>
|
||
<td>{{join .Aliases}}</td>
|
||
<td class="num">{{.Ready}}</td>
|
||
<td class="num">{{.Min}}–{{.Max}}</td>
|
||
<td>{{join .Profiles}}</td>
|
||
<td>{{join .Fallbacks}}</td>
|
||
</tr>
|
||
{{/* Under the row rather than in a column: a sentence in a table cell either
|
||
wraps every other column into a stripe or gets cut off. */}}
|
||
{{if .Description}}<tr><td colspan="6" class="url-row">{{.Description}}</td></tr>{{end}}
|
||
{{end}}</table>
|
||
{{else}}<p class="empty">The catalog is empty.</p>{{end}}
|
||
|
||
{{if .Unplaced}}
|
||
<h2>Why nothing was placed</h2>
|
||
{{range .Unplaced}}
|
||
<p><code>{{.Model}}</code></p>
|
||
<pre>{{range .Rejections}}{{.Profile}} @ {{or .Pool "-"}}{{if .Node}}/{{.Node}}{{end}}: {{.Reason}}
|
||
{{end}}</pre>
|
||
{{end}}
|
||
{{end}}
|
||
|
||
<h2>Replicas</h2>
|
||
{{if .Replicas}}
|
||
<table><tr><th>Replica</th><th>Model</th><th>State</th><th>Pool</th><th>Endpoint</th><th class="num">Age</th><th>Nodes</th></tr>
|
||
{{range .Replicas}}<tr>
|
||
<td><code>{{.ID}}</code></td>
|
||
<td>{{.Model}}</td>
|
||
<td class="state state-{{.State}}">{{.State}}</td>
|
||
<td>{{or .Pool "—"}}</td>
|
||
<td>{{if .Endpoint}}<code>{{.Endpoint}}</code>{{else}}—{{end}}</td>
|
||
<td class="num">{{duration .AgeSec}}</td>
|
||
<td>{{range .Shards}}<code>{{.Node}}</code> {{end}}</td>
|
||
</tr>{{if .Err}}<tr><td colspan="7" class="note">{{.Err}}</td></tr>{{end}}
|
||
{{end}}</table>
|
||
{{else}}<p class="empty">No replicas.</p>{{end}}
|
||
|
||
<h2>Traffic <span class="note">— last 24 hours, hourly</span></h2>
|
||
{{if .HasCharts}}
|
||
<p class="note">Requests</p>{{.RequestChart}}
|
||
<div class="chart-row">
|
||
<div><p class="note">Tokens</p>{{.TokenChart}}</div>
|
||
<div><p class="note">Mean latency</p>{{.LatencyChart}}</div>
|
||
</div>
|
||
{{else if .AuthEnabled}}
|
||
<p class="empty">No traffic recorded yet.</p>
|
||
{{end}}
|
||
|
||
<h2>Usage <span class="note">— last 24 hours</span></h2>
|
||
{{if .AuthEnabled}}
|
||
{{if .Usage}}
|
||
<table><tr><th>Key</th><th>Project</th><th class="num">Requests</th><th class="num">Errors</th><th class="num">Prompt</th><th class="num">Completion</th><th class="num">Spend</th><th>Last</th></tr>
|
||
{{range .Usage}}<tr>
|
||
<td>{{if .KeyName}}{{.KeyName}} <span class="note">{{.KeyPrefix}}</span>{{else}}<code>{{.KeyID}}</code>{{end}}</td>
|
||
<td>{{.ProjectID}}</td>
|
||
<td class="num">{{.Requests}}</td>
|
||
<td class="num">{{if .Errors}}<span class="state-failed">{{.Errors}}</span>{{else}}0{{end}}</td>
|
||
<td class="num">{{.Prompt}}</td>
|
||
<td class="num">{{.Completion}}</td>
|
||
<td class="num">{{units .CostMicros}}</td>
|
||
<td><span class="note">{{ago .LastAt}}</span></td>
|
||
</tr>{{end}}</table>
|
||
<p class="note">Spend is in units, whatever a unit was configured to mean. Usage is
|
||
written in batches, so the last few seconds may be missing.</p>
|
||
{{else}}<p class="empty">No requests recorded in the last 24 hours.</p>{{end}}
|
||
{{else}}
|
||
<p class="empty">Authentication is off, so there is no identity to account against.</p>
|
||
{{end}}
|
||
|
||
{{if not .OnAdminListener}}
|
||
<p class="note">Metrics, pprof and the management API are on the admin listener,
|
||
which is bound to loopback and not reachable from here. pprof can dump the
|
||
process heap, where API keys sit in plaintext, which is why that port is not
|
||
exposed:
|
||
<code>ssh -L 9090:127.0.0.1:9090 <control-plane></code></p>
|
||
{{end}}
|
||
|
||
<footer>
|
||
<span>llmgw {{.Version}} · refreshes every {{.RefreshSec}}s</span>
|
||
<span>{{.Now}}</span>
|
||
</footer>
|
||
</div></body></html>{{end}}
|
||
|
||
{{/* One chart: the SVG, its legend, and the sentence that says how to read it.
|
||
A legend is always present for two lines or more — colour alone is not an
|
||
encoding a reader can rely on — and a single line needs none, because the
|
||
title already names it. */}}
|
||
{{define "tschart"}}
|
||
<div class="chart-wrap">
|
||
<h3 class="chart-title">{{.title}}</h3>
|
||
{{if .t.Empty}}
|
||
<p class="empty">Nothing recorded yet.</p>
|
||
{{else}}
|
||
{{.t.SVG}}
|
||
<span class="chart-peak">peak {{.t.Peak}}</span>
|
||
{{if gt (len .t.Legend) 1}}
|
||
<div class="legend">
|
||
{{range .t.Legend}}<span><i style="background:var({{cssvar .Colour}})"></i>{{.Name}} <b>{{.Last}}</b></span>{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
<p class="note">{{.note}}</p>
|
||
</div>
|
||
{{end}}
|