Files
oierandClaude Opus 5 00984e1a70 feat(monitor): dashboard tiles + TPS via allowlisted RCON
Phases 2 and 3 of plan/23-monitor.md. /api/summary adds: server online/MOTD/
version, players online with NMSR heads, registered-account count, disk free on
the world volume, backup freshness, join/leave history, and TPS.

Each tile has its own TTL cache and its own failure mode. A failed collector
keeps serving its last good value and reports the reason in a sibling *_err
field, so the page goes stale-with-a-warning rather than blank. The five
collectors refresh concurrently: sequentially, one request during an outage
would cost the sum of every timeout (~15s), and an outage is exactly when
someone is looking at this page. Measured with a dead dependency: 0.24s.

TPS needs RCON, so the monitor now holds RCON_PASSWORD. Read-only survives
because the allowlist is a compile-time constant and no HTTP input reaches a
command string — the UI cannot run commands because the server cannot be asked
to. The Source RCON client is implemented in-repo rather than added as a
dependency precisely to keep that property local and auditable.

Two things the real server corrected:

- `neoforge tps` prints "Overall: 20.000 TPS (0.357 ms/tick)", not the older
  Forge "Mean tick time / Mean TPS" wording the plan assumed. main_test.go
  pins the real captured output so a format change fails a test instead of
  silently zeroing the tile.

- Rosters come from mc-status, which already holds the Drasl admin credentials
  and exposes a sanitized list, so this container does not get a second copy.

Also fixes a phase-1 bug: the UI's level regex assumed itzg's console timestamp
([12:34:56]) but the file uses [01Aug2026 22:55:34.358], so every line was
classified INFO and the ERROR/WARN chips did nothing. It now matches on shape.

The container runs as root deliberately — /data is drwxr-x--- owned by uid 1000,
so a non-root user cannot traverse it and the tailer would silently read
nothing. The :ro mounts and absent port are what bound the damage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:16:11 +02:00

364 lines
10 KiB
Go

// Dashboard collectors (phase 2 + 3 of plan/23-monitor.md).
//
// Every tile is fed by its own independently-cached collector. That isolation
// is the whole design: mc-status being slow, the backups dir being unreadable,
// or RCON refusing a connection must degrade exactly one tile and never block
// the summary response or the log stream. On error a collector keeps serving
// its last good value and reports the error alongside it — stale-with-a-reason
// beats a blank page, and beats a spinner that never resolves.
package main
import (
"encoding/json"
"net/http"
"os"
"regexp"
"strings"
"sync"
"syscall"
"time"
)
// --- TTL cache -------------------------------------------------------------
// cached memoizes one collector for ttl. Zero value is not usable; see newCached.
type cached[T any] struct {
mu sync.Mutex
val T
have bool
lastErr string
expires time.Time
ttl time.Duration
fn func() (T, error)
}
func newCached[T any](ttl time.Duration, fn func() (T, error)) *cached[T] {
return &cached[T]{ttl: ttl, fn: fn}
}
// get returns the cached value (refreshing if stale) and the last error, if any.
// It never returns an error *instead* of a value — a failed refresh yields the
// previous value plus the error text.
func (c *cached[T]) get() (T, string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.have && time.Now().Before(c.expires) {
return c.val, c.lastErr
}
v, err := c.fn()
c.expires = time.Now().Add(c.ttl)
if err != nil {
c.lastErr = err.Error()
return c.val, c.lastErr // stale-or-zero, with the reason
}
c.val, c.have, c.lastErr = v, true, ""
return c.val, ""
}
// --- server status + rosters (via mc-status over mcnet) --------------------
// The monitor deliberately does NOT hold Drasl admin credentials. mc-status
// already logs in server-side and exposes a sanitized [{uuid,name}], so we read
// its output instead of duplicating the secret. See plan/15-mc-status.md.
type mcPlayer struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
type statusInfo struct {
Online bool `json:"online"`
Count int64 `json:"count"`
Max int64 `json:"max"`
MOTD string `json:"motd"`
Version string `json:"version"`
Players []mcPlayer `json:"players"`
}
// v2 subset of the mcstatus.io shape mc-status re-emits.
type v2Wire struct {
Online bool `json:"online"`
Players struct {
Online int64 `json:"online"`
Max int64 `json:"max"`
List []struct {
UUID string `json:"uuid"`
NameClean string `json:"name_clean"`
} `json:"list"`
} `json:"players"`
MOTD struct {
Clean string `json:"clean"`
} `json:"motd"`
Version struct {
NameClean string `json:"name_clean"`
} `json:"version"`
}
func fetchStatus(client *http.Client, base string) (statusInfo, error) {
var wire v2Wire
// The address segment is ignored by mc-status (it only ever pings its own
// configured target) — any placeholder works.
if err := getJSON(client, base+"/v2/status/java/self", &wire); err != nil {
return statusInfo{}, err
}
out := statusInfo{
Online: wire.Online,
Count: wire.Players.Online,
Max: wire.Players.Max,
MOTD: wire.MOTD.Clean,
Version: wire.Version.NameClean,
}
for _, p := range wire.Players.List {
out.Players = append(out.Players, mcPlayer{UUID: p.UUID, Name: p.NameClean})
}
return out, nil
}
func fetchRegistered(client *http.Client, base string) ([]mcPlayer, error) {
var players []mcPlayer
if err := getJSON(client, base+"/players", &players); err != nil {
return nil, err
}
return players, nil
}
func getJSON(client *http.Client, url string, dst any) error {
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &statusCodeError{url: url, code: resp.StatusCode}
}
return json.NewDecoder(resp.Body).Decode(dst)
}
type statusCodeError struct {
url string
code int
}
func (e *statusCodeError) Error() string {
return e.url + ": HTTP " + itoa(e.code)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// --- disk ------------------------------------------------------------------
type diskInfo struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPct float64 `json:"used_pct"`
}
// diskUsage statfs's the world volume. This reports the underlying host
// filesystem, which is exactly what matters: a full disk is the failure that
// actually takes the server down, and backups (15G+ and growing) share it.
func diskUsage(path string) (diskInfo, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return diskInfo{Path: path}, err
}
bs := uint64(st.Bsize)
total := st.Blocks * bs
free := st.Bavail * bs // Bavail, not Bfree — exclude root-reserved blocks
info := diskInfo{Path: path, TotalBytes: total, FreeBytes: free}
if total > 0 {
info.UsedPct = float64(total-free) / float64(total) * 100
}
return info, nil
}
// --- backups ---------------------------------------------------------------
type backupInfo struct {
Newest string `json:"newest"`
SizeBytes int64 `json:"size_bytes"`
Mtime int64 `json:"mtime"`
AgeSeconds int64 `json:"age_seconds"`
Count int `json:"count"`
TotalBytes int64 `json:"total_bytes"`
}
// backupStatus finds the newest archive written by mc-backup (world-*.tar.gz).
// Age is the number that matters — "backups exist" is not the same claim as
// "a backup ran recently", and only the second one is worth a tile.
func backupStatus(dir string) (backupInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return backupInfo{}, err
}
var out backupInfo
var newest time.Time
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out.Count++
out.TotalBytes += fi.Size()
if fi.ModTime().After(newest) {
newest = fi.ModTime()
out.Newest = e.Name()
out.SizeBytes = fi.Size()
}
}
if out.Count > 0 {
out.Mtime = newest.Unix()
out.AgeSeconds = int64(time.Since(newest).Seconds())
}
return out, nil
}
// --- session history (derived from the log we already tail) ----------------
type sessionEvent struct {
Time string `json:"time"`
Player string `json:"player"`
Action string `json:"action"` // "joined" | "left"
}
// Matches the message tail of e.g.
//
// [01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft...]: Oier joined the game
var sessionRe = regexp.MustCompile(`]: (\S+) (joined|left) the game\b`)
// leading [timestamp] of a log line, whatever its format
var stampRe = regexp.MustCompile(`^\[([^\]]+)\]`)
// events is a bounded history of joins/leaves, filled by the tailer as lines
// arrive. Free: the bytes are already being read for the log stream. It also
// means the cold-start backfill populates history without any persistence.
type events struct {
mu sync.Mutex
buf []sessionEvent
max int
}
func newEvents(max int) *events { return &events{max: max} }
func (e *events) observe(line string) {
m := sessionRe.FindStringSubmatch(line)
if m == nil {
return
}
ev := sessionEvent{Player: m[1], Action: m[2]}
if ts := stampRe.FindStringSubmatch(line); ts != nil {
ev.Time = ts[1]
}
e.mu.Lock()
defer e.mu.Unlock()
e.buf = append(e.buf, ev)
if len(e.buf) > e.max {
e.buf = e.buf[len(e.buf)-e.max:]
}
}
// recent returns up to n events, newest first.
func (e *events) recent(n int) []sessionEvent {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]sessionEvent, 0, n)
for i := len(e.buf) - 1; i >= 0 && len(out) < n; i-- {
out = append(out, e.buf[i])
}
return out
}
// --- assembled summary -----------------------------------------------------
type summary struct {
Server statusInfo `json:"server"`
ServerErr string `json:"server_err,omitempty"`
Registered int `json:"registered"`
RosterErr string `json:"roster_err,omitempty"`
Disk diskInfo `json:"disk"`
DiskErr string `json:"disk_err,omitempty"`
Backup backupInfo `json:"backup"`
BackupErr string `json:"backup_err,omitempty"`
TPS *tpsInfo `json:"tps"`
TPSErr string `json:"tps_err,omitempty"`
Sessions []sessionEvent `json:"sessions"`
AvatarURL string `json:"avatar_url"`
AvatarMode string `json:"avatar_mode"`
Now int64 `json:"now"`
}
// collectors owns every tile source. Assembled once in main and shared.
type collectors struct {
status *cached[statusInfo]
registered *cached[[]mcPlayer]
disk *cached[diskInfo]
backup *cached[backupInfo]
tps *cached[*tpsInfo]
events *events
avatarURL string
avatarMode string
}
func (c *collectors) summary() summary {
// Refresh concurrently. Sequentially, a summary during an outage would cost
// the SUM of every timeout (~15s with mc-status hanging and RCON blackholed)
// — and an outage is exactly when someone is staring at this page. Each
// cached has its own mutex, so this is just fan-out.
var (
wg sync.WaitGroup
status statusInfo
statusErr string
registered []mcPlayer
rosterErr string
disk diskInfo
diskErr string
backup backupInfo
backupErr string
tps *tpsInfo
tpsErr string
)
wg.Add(5)
go func() { defer wg.Done(); status, statusErr = c.status.get() }()
go func() { defer wg.Done(); registered, rosterErr = c.registered.get() }()
go func() { defer wg.Done(); disk, diskErr = c.disk.get() }()
go func() { defer wg.Done(); backup, backupErr = c.backup.get() }()
go func() { defer wg.Done(); tps, tpsErr = c.tps.get() }()
wg.Wait()
return summary{
Server: status,
ServerErr: statusErr,
Registered: len(registered),
RosterErr: rosterErr,
Disk: disk,
DiskErr: diskErr,
Backup: backup,
BackupErr: backupErr,
TPS: tps,
TPSErr: tpsErr,
Sessions: c.events.recent(40),
AvatarURL: c.avatarURL,
AvatarMode: c.avatarMode,
Now: time.Now().Unix(),
}
}