Files
Erupe/server/api/dashboard.go
Houmgaor 44fd637a59 fix(api): use configured channel port in dashboard stats
The dashboard JSON hardcoded `54000 + server_id` as the channel port,
which is wrong whenever operators configure non-default ports in
config.json. Resolve the actual port from `Entrance.Entries[].Channels[]`
via a server_id map mirroring main.go's sid formula.
2026-04-07 09:20:50 +02:00

156 lines
4.5 KiB
Go

package api
import (
_ "embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"time"
"go.uber.org/zap"
)
//go:embed dashboard.html
var dashboardHTML string
var dashboardTmpl = template.Must(template.New("dashboard").Parse(dashboardHTML))
// DashboardStats is the JSON payload returned by GET /api/dashboard/stats.
type DashboardStats struct {
Uptime string `json:"uptime"`
ServerVersion string `json:"serverVersion"`
ClientMode string `json:"clientMode"`
OnlinePlayers int `json:"onlinePlayers"`
TotalAccounts int `json:"totalAccounts"`
TotalCharacters int `json:"totalCharacters"`
Channels []ChannelInfo `json:"channels"`
DatabaseOK bool `json:"databaseOK"`
}
// ChannelInfo describes a single channel server entry from the servers table.
type ChannelInfo struct {
Name string `json:"name"`
Port int `json:"port"`
Players int `json:"players"`
}
// Dashboard serves the embedded HTML dashboard page at /dashboard.
func (s *APIServer) Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := dashboardTmpl.Execute(w, nil); err != nil {
s.logger.Error("Failed to render dashboard", zap.Error(err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// DashboardStatsJSON serves GET /api/dashboard/stats with live server statistics.
func (s *APIServer) DashboardStatsJSON(w http.ResponseWriter, r *http.Request) {
stats := DashboardStats{
ServerVersion: "Erupe-CE",
ClientMode: s.erupeConfig.ClientMode,
}
// Compute uptime.
if !s.startTime.IsZero() {
stats.Uptime = formatDuration(time.Since(s.startTime))
} else {
stats.Uptime = "unknown"
}
// Check database connectivity.
if s.db != nil {
if err := s.db.Ping(); err != nil {
s.logger.Warn("Dashboard: database ping failed", zap.Error(err))
stats.DatabaseOK = false
} else {
stats.DatabaseOK = true
}
}
// Query total accounts.
if s.db != nil {
if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&stats.TotalAccounts); err != nil {
s.logger.Warn("Dashboard: failed to count users", zap.Error(err))
}
}
// Query total characters.
if s.db != nil {
if err := s.db.QueryRow("SELECT COUNT(*) FROM characters").Scan(&stats.TotalCharacters); err != nil {
s.logger.Warn("Dashboard: failed to count characters", zap.Error(err))
}
}
// Build a map from server_id to configured port, mirroring main.go's
// sid assignment: sid = (4096 + si*256) + (16 + ci) where si is the
// entrance entry index and ci is the channel index within that entry.
// Disabled channels still increment ci, matching main.go.
portByServerID := make(map[int]uint16)
if s.erupeConfig != nil {
for si, ee := range s.erupeConfig.Entrance.Entries {
for ci, ce := range ee.Channels {
sid := (4096 + si*256) + (16 + ci)
portByServerID[sid] = ce.Port
}
}
}
// Query channel info from servers table.
if s.db != nil {
rows, err := s.db.Query("SELECT server_id, current_players, world_name, land FROM servers ORDER BY server_id")
if err != nil {
s.logger.Warn("Dashboard: failed to query servers", zap.Error(err))
} else {
defer func() { _ = rows.Close() }()
for rows.Next() {
var serverID, players, land int
var worldName *string
if err := rows.Scan(&serverID, &players, &worldName, &land); err != nil {
s.logger.Warn("Dashboard: failed to scan server row", zap.Error(err))
continue
}
name := "Channel"
if worldName != nil {
name = *worldName
}
port := 0
if p, ok := portByServerID[serverID]; ok {
port = int(p)
}
ch := ChannelInfo{
Name: name,
Port: port,
Players: players,
}
stats.Channels = append(stats.Channels, ch)
stats.OnlinePlayers += players
}
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
s.logger.Error("Dashboard: failed to encode stats", zap.Error(err))
}
}
// formatDuration produces a human-readable duration string like "2d 5h 32m 10s".
func formatDuration(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
}
if minutes > 0 {
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
return fmt.Sprintf("%ds", seconds)
}