// 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(), } }