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>
364 lines
10 KiB
Go
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(),
|
|
}
|
|
}
|