mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
feat: add SQLite support, setup wizard enhancements, and live dashboard
Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.
Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.
CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.
Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
This commit is contained in:
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
dbutil "erupe-ce/common/db"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -33,6 +35,7 @@ type APIServer struct {
|
||||
sessionRepo APISessionRepo
|
||||
eventRepo APIEventRepo
|
||||
httpServer *http.Server
|
||||
startTime time.Time
|
||||
isShuttingDown bool
|
||||
}
|
||||
|
||||
@@ -45,19 +48,26 @@ func NewAPIServer(config *Config) *APIServer {
|
||||
httpServer: &http.Server{},
|
||||
}
|
||||
if config.DB != nil {
|
||||
s.userRepo = NewAPIUserRepository(config.DB)
|
||||
s.charRepo = NewAPICharacterRepository(config.DB)
|
||||
s.sessionRepo = NewAPISessionRepository(config.DB)
|
||||
s.eventRepo = NewAPIEventRepository(config.DB)
|
||||
wdb := dbutil.Wrap(config.DB)
|
||||
s.userRepo = NewAPIUserRepository(wdb)
|
||||
s.charRepo = NewAPICharacterRepository(wdb)
|
||||
s.sessionRepo = NewAPISessionRepository(wdb)
|
||||
s.eventRepo = NewAPIEventRepository(wdb)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start starts the server in a new goroutine.
|
||||
func (s *APIServer) Start() error {
|
||||
s.startTime = time.Now()
|
||||
|
||||
// Set up the routes responsible for serving the launcher HTML, serverlist, unique name check, and JP auth.
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Dashboard routes (before catch-all)
|
||||
r.HandleFunc("/dashboard", s.Dashboard)
|
||||
r.HandleFunc("/api/dashboard/stats", s.DashboardStatsJSON).Methods("GET")
|
||||
|
||||
// Legacy routes (unchanged, no method enforcement)
|
||||
r.HandleFunc("/launcher", s.Launcher)
|
||||
r.HandleFunc("/login", s.Login)
|
||||
|
||||
137
server/api/dashboard.go
Normal file
137
server/api/dashboard.go
Normal file
@@ -0,0 +1,137 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
ch := ChannelInfo{
|
||||
Name: name,
|
||||
Port: 54000 + serverID,
|
||||
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)
|
||||
}
|
||||
145
server/api/dashboard.html
Normal file
145
server/api/dashboard.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Erupe Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;min-height:100vh;padding:2rem}
|
||||
.header{text-align:center;margin-bottom:2rem}
|
||||
.header h1{font-size:2rem;color:#e94560;margin-bottom:.25rem}
|
||||
.header .version{font-size:.9rem;color:#888}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
|
||||
.card{background:#16213e;border-radius:10px;padding:1.25rem;text-align:center;box-shadow:0 4px 16px rgba(0,0,0,.3)}
|
||||
.card .label{font-size:.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}
|
||||
.card .value{font-size:1.75rem;font-weight:700;color:#4ecdc4}
|
||||
.card .value.accent{color:#e94560}
|
||||
.card .value.ok{color:#4ecdc4}
|
||||
.card .value.fail{color:#e94560}
|
||||
.channels{background:#16213e;border-radius:10px;padding:1.5rem;box-shadow:0 4px 16px rgba(0,0,0,.3);margin-bottom:1.5rem}
|
||||
.channels h2{font-size:1.1rem;color:#e94560;margin-bottom:1rem}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;font-size:.75rem;color:#888;text-transform:uppercase;letter-spacing:.05em;padding:.5rem .75rem;border-bottom:1px solid #0f3460}
|
||||
td{padding:.6rem .75rem;border-bottom:1px solid rgba(15,52,96,.5)}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:.5rem;vertical-align:middle}
|
||||
.dot.active{background:#4ecdc4}
|
||||
.dot.empty{background:#555}
|
||||
.footer{text-align:center;font-size:.8rem;color:#555}
|
||||
.no-channels{color:#888;font-style:italic;padding:1rem 0}
|
||||
.error-banner{background:#e94560;color:#fff;text-align:center;padding:.75rem;border-radius:8px;margin-bottom:1rem;display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Erupe Dashboard</h1>
|
||||
<div class="version" id="version">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="error-banner" class="error-banner">Failed to fetch server stats</div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="label">Uptime</div>
|
||||
<div class="value" id="uptime" style="font-size:1.25rem">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Online Players</div>
|
||||
<div class="value accent" id="online-players">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Total Accounts</div>
|
||||
<div class="value" id="total-accounts">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Total Characters</div>
|
||||
<div class="value" id="total-characters">--</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Database</div>
|
||||
<div class="value" id="db-status">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channels">
|
||||
<h2>Channels</h2>
|
||||
<div id="channels-content">
|
||||
<div class="no-channels">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Last updated: <span id="last-updated">never</span> | Auto-refreshes every 5s
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var lastUpdated = null;
|
||||
|
||||
function updateStats() {
|
||||
fetch("/api/dashboard/stats")
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(d) {
|
||||
document.getElementById("error-banner").style.display = "none";
|
||||
document.getElementById("version").textContent = d.serverVersion + " - " + d.clientMode;
|
||||
document.getElementById("uptime").textContent = d.uptime;
|
||||
document.getElementById("online-players").textContent = d.onlinePlayers;
|
||||
document.getElementById("total-accounts").textContent = d.totalAccounts;
|
||||
document.getElementById("total-characters").textContent = d.totalCharacters;
|
||||
|
||||
var dbEl = document.getElementById("db-status");
|
||||
if (d.databaseOK) {
|
||||
dbEl.textContent = "OK";
|
||||
dbEl.className = "value ok";
|
||||
} else {
|
||||
dbEl.textContent = "DOWN";
|
||||
dbEl.className = "value fail";
|
||||
}
|
||||
|
||||
var container = document.getElementById("channels-content");
|
||||
if (!d.channels || d.channels.length === 0) {
|
||||
container.innerHTML = '<div class="no-channels">No channels registered</div>';
|
||||
} else {
|
||||
var html = '<table><thead><tr><th>Status</th><th>Name</th><th>Port</th><th>Players</th></tr></thead><tbody>';
|
||||
for (var i = 0; i < d.channels.length; i++) {
|
||||
var ch = d.channels[i];
|
||||
var dotClass = ch.players > 0 ? "active" : "empty";
|
||||
html += '<tr><td><span class="dot ' + dotClass + '"></span></td>';
|
||||
html += '<td>' + escapeHtml(ch.name) + '</td>';
|
||||
html += '<td>' + ch.port + '</td>';
|
||||
html += '<td>' + ch.players + '</td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
lastUpdated = new Date();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById("error-banner").style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
if (lastUpdated) {
|
||||
var ago = Math.floor((Date.now() - lastUpdated.getTime()) / 1000);
|
||||
document.getElementById("last-updated").textContent = ago + "s ago";
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var div = document.createElement("div");
|
||||
div.appendChild(document.createTextNode(s));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
updateStats();
|
||||
setInterval(updateStats, 5000);
|
||||
setInterval(updateTimer, 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,16 +3,16 @@ package api
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
dbutil "erupe-ce/common/db"
|
||||
)
|
||||
|
||||
// APICharacterRepository implements APICharacterRepo with PostgreSQL.
|
||||
type APICharacterRepository struct {
|
||||
db *sqlx.DB
|
||||
db *dbutil.DB
|
||||
}
|
||||
|
||||
// NewAPICharacterRepository creates a new APICharacterRepository.
|
||||
func NewAPICharacterRepository(db *sqlx.DB) *APICharacterRepository {
|
||||
func NewAPICharacterRepository(db *dbutil.DB) *APICharacterRepository {
|
||||
return &APICharacterRepository{db: db}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,15 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
dbutil "erupe-ce/common/db"
|
||||
)
|
||||
|
||||
type apiEventRepository struct {
|
||||
db *sqlx.DB
|
||||
db *dbutil.DB
|
||||
}
|
||||
|
||||
// NewAPIEventRepository creates an APIEventRepo backed by PostgreSQL.
|
||||
func NewAPIEventRepository(db *sqlx.DB) APIEventRepo {
|
||||
func NewAPIEventRepository(db *dbutil.DB) APIEventRepo {
|
||||
return &apiEventRepository{db: db}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@ package api
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
dbutil "erupe-ce/common/db"
|
||||
)
|
||||
|
||||
// APISessionRepository implements APISessionRepo with PostgreSQL.
|
||||
type APISessionRepository struct {
|
||||
db *sqlx.DB
|
||||
db *dbutil.DB
|
||||
}
|
||||
|
||||
// NewAPISessionRepository creates a new APISessionRepository.
|
||||
func NewAPISessionRepository(db *sqlx.DB) *APISessionRepository {
|
||||
func NewAPISessionRepository(db *dbutil.DB) *APISessionRepository {
|
||||
return &APISessionRepository{db: db}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
dbutil "erupe-ce/common/db"
|
||||
)
|
||||
|
||||
// APIUserRepository implements APIUserRepo with PostgreSQL.
|
||||
type APIUserRepository struct {
|
||||
db *sqlx.DB
|
||||
db *dbutil.DB
|
||||
}
|
||||
|
||||
// NewAPIUserRepository creates a new APIUserRepository.
|
||||
func NewAPIUserRepository(db *sqlx.DB) *APIUserRepository {
|
||||
func NewAPIUserRepository(db *dbutil.DB) *APIUserRepository {
|
||||
return &APIUserRepository{db: db}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user