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>
This commit is contained in:
1 parent
53604a6127
commit
00984e1a70
7 files changed
+958
-20
No files matched your search
@@ -0,0 +1,363 @@
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
+89
-6
@@ -1,8 +1,14 @@
|
||||
// monitor — admin-only, read-only view of the Minecraft server's latest.log.
|
||||
//
|
||||
// Phase 1 (see plan/23-monitor.md): a tailer that follows /data/logs/latest.log
|
||||
// into an in-memory ring buffer, a polling JSON API over that buffer, and an
|
||||
// embedded single-page UI. Nothing else. No RCON, no dashboard tiles yet.
|
||||
// See plan/23-monitor.md. A tailer follows /data/logs/latest.log into an
|
||||
// in-memory ring buffer (/api/log), a set of independently-cached collectors
|
||||
// feed the dashboard tiles (/api/summary, collect.go), and both are served to
|
||||
// an embedded single-page UI by polling.
|
||||
//
|
||||
// The service is read-only in every sense. /data and /backups are mounted :ro,
|
||||
// no route mutates anything, and the single credential it holds — the RCON
|
||||
// password — can only send commands from a compile-time allowlist that no HTTP
|
||||
// input can reach (rcon.go).
|
||||
//
|
||||
// Design notes that are NOT obvious from the code:
|
||||
//
|
||||
@@ -26,7 +32,12 @@
|
||||
// disk, so a restart costs nothing that isn't still there.
|
||||
//
|
||||
// This service publishes no port (mcnet only) and is reached exclusively through
|
||||
// caddy's basic_auth vhost. It holds no credentials in this phase.
|
||||
// caddy's basic_auth vhost.
|
||||
//
|
||||
// It runs as root inside the container ON PURPOSE: /data is drwxr-x--- owned by
|
||||
// uid 1000 (itzg), so a non-root user cannot even traverse it and the tailer
|
||||
// would silently read nothing. Every mount is :ro, which is what actually bounds
|
||||
// the damage here.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -34,11 +45,13 @@ import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -148,6 +161,7 @@ type tailer struct {
|
||||
path string
|
||||
backfill int
|
||||
r *ring
|
||||
ev *events // session history, derived from the same bytes (may be nil)
|
||||
|
||||
f *os.File
|
||||
pos int64 // byte offset of the next unread COMPLETE line
|
||||
@@ -273,7 +287,11 @@ func (t *tailer) drain(size int64) {
|
||||
return
|
||||
}
|
||||
t.pos += int64(len(line))
|
||||
t.r.append(trimEOL(line))
|
||||
clean := trimEOL(line)
|
||||
t.r.append(clean)
|
||||
if t.ev != nil {
|
||||
t.ev.observe(clean)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,9 +377,12 @@ func main() {
|
||||
}
|
||||
|
||||
r := newRing(bufLines)
|
||||
t := &tailer{path: path, backfill: bufLines, r: r, state: tailState{Path: path}}
|
||||
ev := newEvents(500)
|
||||
t := &tailer{path: path, backfill: bufLines, r: r, ev: ev, state: tailState{Path: path}}
|
||||
go t.run(interval)
|
||||
|
||||
col := buildCollectors(ev)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// GET /api/log[?since=<seq>] — omit `since` for a full buffer without a
|
||||
@@ -386,6 +407,15 @@ func main() {
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// GET /api/summary — every dashboard tile in one response. Each collector is
|
||||
// independently cached and degrades on its own; a failure shows up as a
|
||||
// *_err field next to a stale value rather than failing the request.
|
||||
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(col.summary())
|
||||
})
|
||||
|
||||
// Liveness only. Deliberately NOT tied to the log file: Minecraft being down
|
||||
// is a legitimate state, and Uptime Kuma probes this over mcnet (bypassing
|
||||
// caddy, hence unauthenticated by construction).
|
||||
@@ -416,3 +446,56 @@ func envOr(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envDur(key, def string) time.Duration {
|
||||
d, err := time.ParseDuration(envOr(key, def))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid %s: %v", key, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// buildCollectors wires every dashboard tile source. TTLs are per-collector and
|
||||
// chosen against what each thing actually costs: mc-status is a local HTTP hop
|
||||
// (cheap, and it caches its own upstream ping), statfs is free, the backups dir
|
||||
// is a directory scan, RCON opens a socket to the game server.
|
||||
func buildCollectors(ev *events) *collectors {
|
||||
base := strings.TrimRight(envOr("MONITOR_MCSTATUS_URL", "http://mc-status:8080"), "/")
|
||||
dataDir := envOr("MONITOR_DATA_DIR", "/data")
|
||||
backupDir := envOr("MONITOR_BACKUP_DIR", "/backups")
|
||||
|
||||
// Short timeout everywhere: a hung dependency must not hold the summary.
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
rconAddr := envOr("MONITOR_RCON_ADDR", "minecraft:25575")
|
||||
rconPass := os.Getenv("MONITOR_RCON_PASSWORD")
|
||||
tpsInterval := envDur("MONITOR_TPS_INTERVAL", "30s")
|
||||
|
||||
return &collectors{
|
||||
status: newCached(envDur("MONITOR_STATUS_TTL", "15s"), func() (statusInfo, error) {
|
||||
return fetchStatus(client, base)
|
||||
}),
|
||||
registered: newCached(envDur("MONITOR_ROSTER_TTL", "60s"), func() ([]mcPlayer, error) {
|
||||
return fetchRegistered(client, base)
|
||||
}),
|
||||
disk: newCached(envDur("MONITOR_DISK_TTL", "60s"), func() (diskInfo, error) {
|
||||
return diskUsage(dataDir)
|
||||
}),
|
||||
backup: newCached(envDur("MONITOR_BACKUP_TTL", "60s"), func() (backupInfo, error) {
|
||||
return backupStatus(backupDir)
|
||||
}),
|
||||
tps: newCached(tpsInterval, func() (*tpsInfo, error) {
|
||||
if rconPass == "" {
|
||||
return nil, errors.New("rcon: MONITOR_RCON_PASSWORD unset (TPS disabled)")
|
||||
}
|
||||
raw, err := rconQuery(rconAddr, rconPass, "neoforge tps", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseTPS(raw)
|
||||
}),
|
||||
events: ev,
|
||||
avatarURL: strings.TrimRight(envOr("MONITOR_AVATAR_URL", ""), "/"),
|
||||
avatarMode: envOr("MONITOR_AVATAR_MODE", "headiso"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
// Locks the two things that are easy to break silently: the RCON allowlist
|
||||
// (the guarantee that this service cannot run commands) and the `neoforge tps`
|
||||
// output format, which differs from the older Forge wording.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTPSRealOutput(t *testing.T) {
|
||||
// Captured verbatim from `docker exec minecraft rcon-cli neoforge tps` on prod,
|
||||
// ANSI resets included.
|
||||
raw := "Overworld: 20.000 TPS (0.298 ms/tick)\n\x1b[0mThe Nether: 19.100 TPS (0.014 ms/tick)\n" +
|
||||
"\x1b[0mworld_dimension_nexus:creative: 20.000 TPS (0.009 ms/tick)\n" +
|
||||
"\x1b[0mOverall: 18.500 TPS (0.357 ms/tick)\n\x1b[0m"
|
||||
got, err := parseTPS(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.TPS != 18.5 || got.MSPT != 0.357 {
|
||||
t.Fatalf("overall wrong: %+v", got)
|
||||
}
|
||||
if len(got.Dimensions) != 3 {
|
||||
t.Fatalf("want 3 dims, got %d: %+v", len(got.Dimensions), got.Dimensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTPSUnknownCommand(t *testing.T) {
|
||||
if _, err := parseTPS("Unknown or incomplete command, see below for error\nforge tps<--[HERE]"); err == nil {
|
||||
t.Fatal("want error for unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRCONAllowlist(t *testing.T) {
|
||||
for _, bad := range []string{"op Oier", "stop", "say hi", "ban x", "", "neoforge tps ", "LIST"} {
|
||||
if _, err := rconQuery("127.0.0.1:1", "pw", bad, 0); err != errRCONNotAllowed {
|
||||
t.Fatalf("command %q was not rejected by the allowlist (err=%v)", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParse(t *testing.T) {
|
||||
e := newEvents(10)
|
||||
e.observe("[01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier joined the game")
|
||||
e.observe("[01Aug2026 23:01:02.111] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier left the game")
|
||||
e.observe("[01Aug2026 23:02:00.000] [Server thread/INFO] [x/]: something else entirely")
|
||||
got := e.recent(10)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 events, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Action != "left" || got[0].Player != "Oier" || got[0].Time != "01Aug2026 23:01:02.111" {
|
||||
t.Fatalf("newest-first wrong: %+v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Minimal Source RCON client — the ONLY thing in this service that talks to
|
||||
// Minecraft rather than reading a file, and the only place it holds a secret.
|
||||
//
|
||||
// Implemented here rather than pulled in as a dependency for one reason: the
|
||||
// allowlist below. It is a compile-time constant, and there is deliberately no
|
||||
// code path from an HTTP request to a command string. The UI cannot run
|
||||
// commands because the server cannot be asked to run one — "read-only" is a
|
||||
// property of the build, not a rule someone has to remember.
|
||||
//
|
||||
// Protocol: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
|
||||
// A fresh connection per poll (every 30s). Reconnecting costs nothing at that
|
||||
// rate and removes all stale-connection handling.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
rconTypeAuth int32 = 3
|
||||
rconTypeExec int32 = 2
|
||||
rconTypeAuthResp int32 = 2
|
||||
rconMaxBody = 4096
|
||||
)
|
||||
|
||||
// rconAllowed is the complete set of commands this service can ever send.
|
||||
// Both are pure reads. Do NOT make this configurable — see the file header.
|
||||
var rconAllowed = map[string]bool{
|
||||
"neoforge tps": true,
|
||||
"list": true,
|
||||
}
|
||||
|
||||
var errRCONNotAllowed = errors.New("rcon: command not in allowlist")
|
||||
|
||||
// rconQuery dials, authenticates, runs one allowlisted command, and closes.
|
||||
func rconQuery(addr, password, cmd string, timeout time.Duration) (string, error) {
|
||||
if !rconAllowed[cmd] {
|
||||
return "", errRCONNotAllowed
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
if err := rconSend(conn, 1, rconTypeAuth, password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Some servers emit an empty RESPONSE_VALUE before the auth result; read
|
||||
// until the auth response arrives (bounded, so a chatty peer can't spin us).
|
||||
for i := 0; i < 3; i++ {
|
||||
id, typ, _, err := rconRecv(conn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if typ != rconTypeAuthResp {
|
||||
continue
|
||||
}
|
||||
if id == -1 {
|
||||
return "", errors.New("rcon: authentication failed (bad password)")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if err := rconSend(conn, 2, rconTypeExec, cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Responses may span several packets. Read until the peer goes quiet.
|
||||
var sb strings.Builder
|
||||
for {
|
||||
_, _, body, err := rconRecv(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
sb.WriteString(body)
|
||||
if sb.Len() > rconMaxBody*4 {
|
||||
break
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", errors.New("rcon: empty response")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func rconSend(w io.Writer, id, typ int32, body string) error {
|
||||
buf := new(bytes.Buffer)
|
||||
// size counts everything after itself: id + type + body + two NUL bytes.
|
||||
binary.Write(buf, binary.LittleEndian, int32(10+len(body)))
|
||||
binary.Write(buf, binary.LittleEndian, id)
|
||||
binary.Write(buf, binary.LittleEndian, typ)
|
||||
buf.WriteString(body)
|
||||
buf.Write([]byte{0, 0})
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func rconRecv(r io.Reader) (id, typ int32, body string, err error) {
|
||||
var size int32
|
||||
if err = binary.Read(r, binary.LittleEndian, &size); err != nil {
|
||||
return
|
||||
}
|
||||
if size < 10 || size > rconMaxBody+16 {
|
||||
err = errors.New("rcon: implausible packet size " + strconv.Itoa(int(size)))
|
||||
return
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err = io.ReadFull(r, buf); err != nil {
|
||||
return
|
||||
}
|
||||
id = int32(binary.LittleEndian.Uint32(buf[0:4]))
|
||||
typ = int32(binary.LittleEndian.Uint32(buf[4:8]))
|
||||
body = string(bytes.TrimRight(buf[8:], "\x00"))
|
||||
return
|
||||
}
|
||||
|
||||
// --- TPS ---------------------------------------------------------------
|
||||
|
||||
// tpsInfo is the parsed result of `neoforge tps`.
|
||||
type tpsInfo struct {
|
||||
TPS float64 `json:"tps"`
|
||||
MSPT float64 `json:"mspt"`
|
||||
Dimensions []dimensionT `json:"dimensions"`
|
||||
}
|
||||
|
||||
type dimensionT struct {
|
||||
Name string `json:"name"`
|
||||
TPS float64 `json:"tps"`
|
||||
MSPT float64 `json:"mspt"`
|
||||
}
|
||||
|
||||
// NeoForge 1.21.1 prints one line per dimension then an "Overall:" line:
|
||||
//
|
||||
// Overworld: 20.000 TPS (0.298 ms/tick)
|
||||
// Overall: 20.000 TPS (0.357 ms/tick)
|
||||
//
|
||||
// Verified against the running server — note this is NOT the older Forge
|
||||
// "Mean tick time / Mean TPS" wording, and `forge tps` does not exist here
|
||||
// ("Unknown or incomplete command").
|
||||
var tpsLineRe = regexp.MustCompile(`(?m)^(.+?):\s*([0-9.]+)\s*TPS\s*\(([0-9.]+)\s*ms/tick\)`)
|
||||
|
||||
// colorCodes strips Minecraft §-codes and any ANSI escapes a wrapper added.
|
||||
var colorCodes = regexp.MustCompile("§.|\x1b\\[[0-9;]*m")
|
||||
|
||||
func parseTPS(raw string) (*tpsInfo, error) {
|
||||
clean := colorCodes.ReplaceAllString(raw, "")
|
||||
matches := tpsLineRe.FindAllStringSubmatch(clean, -1)
|
||||
if len(matches) == 0 {
|
||||
// Most likely "Unknown or incomplete command" — treat as unavailable
|
||||
// rather than an error that kills the poll loop.
|
||||
return nil, errors.New("rcon: no TPS lines in response")
|
||||
}
|
||||
|
||||
out := &tpsInfo{}
|
||||
for _, m := range matches {
|
||||
name := strings.TrimSpace(m[1])
|
||||
tps, _ := strconv.ParseFloat(m[2], 64)
|
||||
mspt, _ := strconv.ParseFloat(m[3], 64)
|
||||
if strings.EqualFold(name, "Overall") {
|
||||
out.TPS, out.MSPT = tps, mspt
|
||||
continue
|
||||
}
|
||||
out.Dimensions = append(out.Dimensions, dimensionT{Name: name, TPS: tps, MSPT: mspt})
|
||||
}
|
||||
if out.TPS == 0 && len(out.Dimensions) > 0 {
|
||||
// No "Overall" line — fall back to the worst dimension so the tile still
|
||||
// says something useful rather than showing 0 TPS.
|
||||
worst := out.Dimensions[0]
|
||||
for _, d := range out.Dimensions[1:] {
|
||||
if d.TPS < worst.TPS {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
out.TPS, out.MSPT = worst.TPS, worst.MSPT
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -83,6 +83,53 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
.chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); }
|
||||
.chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); }
|
||||
|
||||
/* --- dashboard tiles --- */
|
||||
#tiles {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tile h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
}
|
||||
.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; }
|
||||
.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.tile .sub.warnish { color: var(--gold); }
|
||||
.tile .sub.badish { color: var(--red); }
|
||||
.good { color: var(--accent); }
|
||||
.warn { color: var(--gold); }
|
||||
.bad { color: var(--red); }
|
||||
|
||||
.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||
.bar > i.warn { background: var(--gold); }
|
||||
.bar > i.bad { background: var(--red); }
|
||||
|
||||
.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||
.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); }
|
||||
.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); }
|
||||
|
||||
.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; }
|
||||
.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sessions .j { color: var(--accent); }
|
||||
.sessions .l { color: var(--dim); }
|
||||
|
||||
#log {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
@@ -131,6 +178,16 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div id="tiles">
|
||||
<div class="tile" id="t-server"> <h2>Server</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-tps"> <h2>TPS</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-players"> <h2>Online</h2> <div class="big">—</div><div class="heads"></div></div>
|
||||
<div class="tile" id="t-reg"> <h2>Registered</h2><div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-disk"> <h2>Disk</h2> <div class="big">—</div><div class="sub"></div><div class="bar"><i></i></div></div>
|
||||
<div class="tile" id="t-backup"> <h2>Last backup</h2><div class="big">—</div><div class="sub"></div></div>
|
||||
<div class="tile" id="t-sessions"><h2>Recent joins</h2><div class="sessions"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="log" role="log" aria-live="off"></div>
|
||||
<button id="live" hidden>jump to live ↓</button>
|
||||
|
||||
@@ -159,8 +216,11 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
let regex = null;
|
||||
const levelOn = { ERROR: true, WARN: true, INFO: true };
|
||||
|
||||
// log4j lines look like: [12:34:56] [Server thread/INFO] [mod/] message
|
||||
const LEVEL_RE = /^\[\d{2}:\d{2}:\d{2}\]\s*\[[^\]]*\/([A-Z]+)\]/;
|
||||
// Two bracket groups, then the level: the timestamp format differs between
|
||||
// itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]),
|
||||
// so match the *shape* rather than the timestamp:
|
||||
// [<anything>] [<thread>/LEVEL] [<logger>]: message
|
||||
const LEVEL_RE = /^\[[^\]]*\]\s*\[[^\]]*\/([A-Z]+)\]/;
|
||||
|
||||
function levelOf(text) {
|
||||
const m = LEVEL_RE.exec(text);
|
||||
@@ -286,6 +346,125 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- dashboard tiles ------------------------------------------------------
|
||||
|
||||
const SUMMARY_MS = 10000; // tiles move far slower than the log does
|
||||
|
||||
const fmtBytes = (n) => {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
|
||||
};
|
||||
|
||||
const fmtAge = (s) => {
|
||||
if (s == null) return "—";
|
||||
if (s < 60) return s + "s ago";
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
||||
return Math.floor(s / 86400) + "d ago";
|
||||
};
|
||||
|
||||
// Set a tile's big value, its class, and its subtitle in one go. `err` wins
|
||||
// over the value: a stale number with no warning would be a lie.
|
||||
function tile(id, big, bigClass, sub, subClass, err) {
|
||||
const el = document.getElementById(id);
|
||||
el.querySelector(".big").textContent = big;
|
||||
el.querySelector(".big").className = "big " + (bigClass || "");
|
||||
const s = el.querySelector(".sub");
|
||||
if (s) {
|
||||
s.textContent = err ? err : (sub || "");
|
||||
s.className = "sub " + (err ? "badish" : (subClass || ""));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderSummary(d) {
|
||||
// Server
|
||||
tile("t-server",
|
||||
d.server.online ? "online" : "offline",
|
||||
d.server.online ? "good" : "bad",
|
||||
d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "",
|
||||
"", d.server_err);
|
||||
|
||||
// TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad.
|
||||
if (d.tps) {
|
||||
const t = d.tps.tps;
|
||||
tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad",
|
||||
d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err);
|
||||
} else {
|
||||
tile("t-tps", "—", "", "", "", d.tps_err || "unavailable");
|
||||
}
|
||||
|
||||
// Online players + heads (rendered by NMSR on its own public origin)
|
||||
const pl = d.server.players || [];
|
||||
tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : "");
|
||||
const heads = document.querySelector("#t-players .heads");
|
||||
heads.replaceChildren();
|
||||
for (const p of pl) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "head";
|
||||
if (d.avatar_url && p.uuid) {
|
||||
const img = document.createElement("img");
|
||||
img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
wrap.appendChild(img);
|
||||
}
|
||||
const nm = document.createElement("span");
|
||||
nm.textContent = p.name || "?";
|
||||
wrap.appendChild(nm);
|
||||
heads.appendChild(wrap);
|
||||
}
|
||||
|
||||
tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err);
|
||||
|
||||
// Disk — the failure that actually takes the server down.
|
||||
const used = d.disk.used_pct || 0;
|
||||
const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good";
|
||||
tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls,
|
||||
used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err);
|
||||
const bar = document.querySelector("#t-disk .bar > i");
|
||||
bar.style.width = Math.min(100, used) + "%";
|
||||
bar.className = cls === "good" ? "" : cls;
|
||||
|
||||
// Backup freshness. mc-backup runs every 6h, so >8h means it stopped.
|
||||
if (d.backup && d.backup.count) {
|
||||
const age = d.backup.age_seconds;
|
||||
tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good",
|
||||
`${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err);
|
||||
} else {
|
||||
tile("t-backup", "none", "bad", "", "", d.backup_err);
|
||||
}
|
||||
|
||||
// Recent joins/leaves, parsed out of the log we already hold.
|
||||
const box = document.querySelector("#t-sessions .sessions");
|
||||
box.replaceChildren();
|
||||
if (!d.sessions || !d.sessions.length) {
|
||||
const e = document.createElement("div");
|
||||
e.textContent = "no joins in buffer";
|
||||
box.appendChild(e);
|
||||
} else {
|
||||
for (const s of d.sessions.slice(0, 12)) {
|
||||
const row = document.createElement("div");
|
||||
const who = document.createElement("span");
|
||||
who.className = s.action === "joined" ? "j" : "l";
|
||||
who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`;
|
||||
row.appendChild(who);
|
||||
row.appendChild(document.createTextNode(" " + (s.time || "")));
|
||||
box.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSummary() {
|
||||
try {
|
||||
const res = await fetch("/api/summary", { cache: "no-store" });
|
||||
if (res.ok) renderSummary(await res.json());
|
||||
} catch { /* tiles keep their last values; the log poll owns the status dot */ }
|
||||
finally { setTimeout(pollSummary, SUMMARY_MS); }
|
||||
}
|
||||
|
||||
// --- filter ---------------------------------------------------------------
|
||||
|
||||
let debounce;
|
||||
@@ -331,6 +510,7 @@ input[type=search].bad-re { border-color: var(--red); }
|
||||
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
|
||||
|
||||
poll();
|
||||
pollSummary();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user