Completes the Go backend so it can fully replace Python in production: tracker-go website layer (serves the unchanged frontend): - static file serving + SPA fallback + /icons (website.go) - login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the /me handler (auth.go issueSessionCookie + website.go) - admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go) - request-scoped user context + requireAdmin (auth.go) cutover ingest (gated off during the parallel run, required for a clean cutover): - inventory forwarding: full_inventory -> /process-inventory, inventory_delta -> item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go) - death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go) - SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go) two bugs found live and fixed: - coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go) - telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll; broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType) docker-compose.cutover.yml: reversible override flipping the Go services to write mode against the production DBs and repointing the Discord bot at the Go /ws/live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
367 lines
9.6 KiB
Go
367 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// coerceNum converts a JSON value to a float64, parsing string-encoded numbers.
|
|
// The plugin sends several telemetry fields as strings (kills_per_hour, deaths,
|
|
// total_deaths, prismatic_taper_count via .ToString()); Python's pydantic
|
|
// coerced them, so Go must too or it writes null/0 (causing the live counters
|
|
// to flap 0<->value between the WS broadcast and the DB-derived /live poll).
|
|
func coerceNum(v any) (float64, bool) {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x, true
|
|
case float32:
|
|
return float64(x), true
|
|
case int:
|
|
return float64(x), true
|
|
case int32:
|
|
return float64(x), true
|
|
case int64:
|
|
return float64(x), true
|
|
case string:
|
|
s := strings.TrimSpace(x)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
return f, err == nil
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// reqCtx returns a child of the request context with a query timeout.
|
|
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(r.Context(), 15*time.Second)
|
|
}
|
|
|
|
func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
|
s.log.Error("db query failed", "where", where, "err", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"})
|
|
}
|
|
|
|
// GET /stats/{character_name} — latest telemetry snapshot + lifetime totals. (main.py:3927)
|
|
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
|
cn := r.PathValue("character_name")
|
|
ctx, cancel := reqCtx(r)
|
|
defer cancel()
|
|
|
|
const sql = `
|
|
WITH latest AS (
|
|
SELECT * FROM telemetry_events
|
|
WHERE character_name = $1
|
|
ORDER BY timestamp DESC LIMIT 1
|
|
)
|
|
SELECT l.*,
|
|
COALESCE(cs.total_kills, 0) AS total_kills,
|
|
COALESCE(rs.total_rares, 0) AS total_rares
|
|
FROM latest l
|
|
LEFT JOIN char_stats cs ON l.character_name = cs.character_name
|
|
LEFT JOIN rare_stats rs ON l.character_name = rs.character_name`
|
|
|
|
row, err := queryRowAsMap(ctx, s.pool, sql, cn)
|
|
if err != nil {
|
|
s.dbErr(w, "stats", err)
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Character not found"})
|
|
return
|
|
}
|
|
totalKills := row["total_kills"]
|
|
totalRares := row["total_rares"]
|
|
delete(row, "total_kills")
|
|
delete(row, "total_rares")
|
|
formatTimes([]map[string]any{row}, "timestamp", "received_at")
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"character_name": cn,
|
|
"latest_snapshot": row,
|
|
"total_kills": totalKills,
|
|
"total_rares": totalRares,
|
|
})
|
|
}
|
|
|
|
// GET /portals — all active portals (cleanup job handles 1h expiry). (main.py:1959)
|
|
func (s *Server) handlePortals(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := reqCtx(r)
|
|
defer cancel()
|
|
|
|
rows, err := queryRowsAsMaps(ctx, s.pool,
|
|
`SELECT portal_name, ns, ew, z, discovered_at, discovered_by FROM portals ORDER BY discovered_at DESC`)
|
|
if err != nil {
|
|
s.dbErr(w, "portals", err)
|
|
return
|
|
}
|
|
portals := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
da := ""
|
|
if t, ok := row["discovered_at"].(time.Time); ok {
|
|
da = pyISO(t)
|
|
}
|
|
portals = append(portals, map[string]any{
|
|
"portal_name": row["portal_name"],
|
|
"coordinates": map[string]any{"ns": row["ns"], "ew": row["ew"], "z": row["z"]},
|
|
"discovered_at": da,
|
|
"discovered_by": row["discovered_by"],
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"portals": portals, "portal_count": len(portals)})
|
|
}
|
|
|
|
// GET /spawns/heatmap?hours=&limit= — aggregated spawn density. (main.py:2037)
|
|
func (s *Server) handleSpawnHeatmap(w http.ResponseWriter, r *http.Request) {
|
|
hours := clampInt(queryInt(r, "hours", 24), 1, 168)
|
|
limit := clampInt(queryInt(r, "limit", 10000), 100, 50000)
|
|
ctx, cancel := reqCtx(r)
|
|
defer cancel()
|
|
|
|
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
|
rows, err := queryRowsAsMaps(ctx, s.pool,
|
|
`SELECT ew, ns, COUNT(*) AS spawn_count FROM spawn_events
|
|
WHERE timestamp >= $1 GROUP BY ew, ns ORDER BY spawn_count DESC LIMIT $2`,
|
|
cutoff, limit)
|
|
if err != nil {
|
|
s.dbErr(w, "spawns/heatmap", err)
|
|
return
|
|
}
|
|
points := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
points = append(points, map[string]any{
|
|
"ew": toFloat(row["ew"]),
|
|
"ns": toFloat(row["ns"]),
|
|
"intensity": toInt(row["spawn_count"]),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"spawn_points": points,
|
|
"total_points": len(points),
|
|
"timestamp": pyISO(time.Now().UTC()),
|
|
"hours_window": hours,
|
|
})
|
|
}
|
|
|
|
// GET /server-health — current Coldeve status + computed uptime. (main.py:1881)
|
|
func (s *Server) handleServerHealth(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := reqCtx(r)
|
|
defer cancel()
|
|
|
|
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM server_status WHERE server_name = $1`, "Coldeve")
|
|
if err != nil {
|
|
s.dbErr(w, "server-health", err)
|
|
return
|
|
}
|
|
|
|
status := "unknown"
|
|
var latency, playerCount, lastRestart, lastCheck any
|
|
var uptimeSeconds int64
|
|
if row != nil {
|
|
if v, ok := row["current_status"].(string); ok && v != "" {
|
|
status = v
|
|
}
|
|
latency = row["last_latency_ms"]
|
|
playerCount = row["last_player_count"]
|
|
uptimeSeconds = toInt64(row["total_uptime_seconds"])
|
|
if t, ok := row["last_restart"].(time.Time); ok {
|
|
lastRestart = pyISO(t)
|
|
}
|
|
if t, ok := row["last_check"].(time.Time); ok {
|
|
lastCheck = pyISO(t)
|
|
}
|
|
}
|
|
days := uptimeSeconds / 86400
|
|
hours := (uptimeSeconds % 86400) / 3600
|
|
minutes := (uptimeSeconds % 3600) / 60
|
|
uptime := fmt.Sprintf("%dh %dm", hours, minutes)
|
|
if days > 0 {
|
|
uptime = fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"server_name": "Coldeve",
|
|
"status": status,
|
|
"latency_ms": latency,
|
|
"player_count": playerCount,
|
|
"uptime": uptime,
|
|
"uptime_seconds": uptimeSeconds,
|
|
"last_restart": lastRestart,
|
|
"last_check": lastCheck,
|
|
})
|
|
}
|
|
|
|
// GET /inventories — characters with stored inventories. (main.py:2212)
|
|
func (s *Server) handleInventories(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := reqCtx(r)
|
|
defer cancel()
|
|
|
|
rows, err := queryRowsAsMaps(ctx, s.pool,
|
|
`SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
|
|
FROM character_inventories GROUP BY character_name ORDER BY last_updated DESC`)
|
|
if err != nil {
|
|
s.dbErr(w, "inventories", err)
|
|
return
|
|
}
|
|
formatTimes(rows, "last_updated")
|
|
chars := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
chars = append(chars, map[string]any{
|
|
"character_name": row["character_name"],
|
|
"item_count": row["item_count"],
|
|
"last_updated": row["last_updated"],
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)})
|
|
}
|
|
|
|
// GET /inventory/{character_name}/search — filtered local inventory rows. (main.py:2135)
|
|
func (s *Server) handleInventorySearch(w http.ResponseWriter, r *http.Request) {
|
|
cn := r.PathValue("character_name")
|
|
name := optStr(r, "name")
|
|
objectClass := optInt(r, "object_class")
|
|
minValue := optInt(r, "min_value")
|
|
maxValue := optInt(r, "max_value")
|
|
minBurden := optInt(r, "min_burden")
|
|
maxBurden := optInt(r, "max_burden")
|
|
|
|
conds := []string{"character_name = $1"}
|
|
args := []any{cn}
|
|
add := func(tmpl string, val any) {
|
|
args = append(args, val)
|
|
conds = append(conds, fmt.Sprintf(tmpl, len(args)))
|
|
}
|
|
if name != nil && *name != "" {
|
|
add("name ILIKE $%d", "%"+*name+"%")
|
|
}
|
|
if objectClass != nil {
|
|
add("object_class = $%d", *objectClass)
|
|
}
|
|
if minValue != nil {
|
|
add("value >= $%d", *minValue)
|
|
}
|
|
if maxValue != nil {
|
|
add("value <= $%d", *maxValue)
|
|
}
|
|
if minBurden != nil {
|
|
add("burden >= $%d", *minBurden)
|
|
}
|
|
if maxBurden != nil {
|
|
add("burden <= $%d", *maxBurden)
|
|
}
|
|
|
|
sql := `SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp
|
|
FROM character_inventories WHERE ` + join(conds, " AND ") + ` ORDER BY value DESC, name`
|
|
|
|
ctx, cancel := reqCtx(r)
|
|
defer cancel()
|
|
rows, err := queryRowsAsMaps(ctx, s.pool, sql, args...)
|
|
if err != nil {
|
|
s.dbErr(w, "inventory-search", err)
|
|
return
|
|
}
|
|
formatTimes(rows, "timestamp")
|
|
for _, row := range rows {
|
|
if v, ok := row["item_data"]; ok {
|
|
row["item_data"] = decodeJSONValue(v)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"character_name": cn,
|
|
"item_count": len(rows),
|
|
"search_criteria": map[string]any{
|
|
"name": derefStr(name),
|
|
"object_class": derefInt(objectClass),
|
|
"min_value": derefInt(minValue),
|
|
"max_value": derefInt(maxValue),
|
|
"min_burden": derefInt(minBurden),
|
|
"max_burden": derefInt(maxBurden),
|
|
},
|
|
"items": rows,
|
|
})
|
|
}
|
|
|
|
// ---- small param/number helpers ----
|
|
|
|
func queryInt(r *http.Request, key string, def int) int {
|
|
if v := r.URL.Query().Get(key); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func optInt(r *http.Request, key string) *int {
|
|
v := r.URL.Query().Get(key)
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &n
|
|
}
|
|
|
|
func optStr(r *http.Request, key string) *string {
|
|
vs := r.URL.Query()
|
|
if !vs.Has(key) {
|
|
return nil
|
|
}
|
|
v := vs.Get(key)
|
|
return &v
|
|
}
|
|
|
|
func derefStr(p *string) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func derefInt(p *int) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func clampInt(v, lo, hi int) int {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|
|
|
|
func join(parts []string, sep string) string {
|
|
out := ""
|
|
for i, p := range parts {
|
|
if i > 0 {
|
|
out += sep
|
|
}
|
|
out += p
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toFloat(v any) float64 {
|
|
f, _ := coerceNum(v)
|
|
return f
|
|
}
|
|
|
|
func toInt(v any) int {
|
|
f, _ := coerceNum(v)
|
|
return int(f)
|
|
}
|
|
|
|
func toInt64(v any) int64 {
|
|
f, _ := coerceNum(v)
|
|
return int64(f)
|
|
}
|