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:
Houmgaor
2026-03-05 18:00:30 +01:00
parent 03adb21e99
commit ecfe58ffb4
86 changed files with 2326 additions and 356 deletions

View File

@@ -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
View 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
View 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>

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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}
}