Fix the Servers page failing when no servers are configured
CI / test (push) Successful in 54s
Release / release (push) Successful in 2m31s
Docker / image (push) Successful in 2m52s

An empty server list was sent as null, so the page threw on .length and
showed only the loading placeholder. The API now always returns a list,
the page tolerates null, and tests cover the empty config and the token.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 23:58:26 -06:00
1 parent 8f01c6213f
commit 4afbf2d266
4 files changed
+78 -3

No files matched your search

+1 -1
View File
@@ -71,7 +71,7 @@ func (a *Admin) overview(w http.ResponseWriter, r *http.Request) {
for _, s := range snap.Servers { for _, s := range snap.Servers {
stats[s.Name] = s stats[s.Name] = s
} }
var servers []serverView servers := []serverView{}
for _, s := range a.Cfg.Current().Servers { for _, s := range a.Cfg.Current().Servers {
v := serverView{Server: s} v := serverView{Server: s}
if h, ok := states[s.Name]; ok { if h, ok := states[s.Name]; ok {
+72
View File
@@ -0,0 +1,72 @@
package admin
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/proxy"
)
func newAdmin(t *testing.T, cfg string) http.Handler {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
path := filepath.Join(t.TempDir(), "spinner.json")
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
m, err := config.NewManager(path, log)
if err != nil {
t.Fatal(err)
}
h := health.New(log)
return (&Admin{Cfg: m, Health: h, Proxy: proxy.New(m, h, log), Token: "secret"}).Handler()
}
func get(h http.Handler, path, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// The page reads .length on these lists, so an empty config must give [] and never null.
func TestEmptyConfigGivesEmptyLists(t *testing.T) {
for _, cfg := range []string{`{}`, `{"servers":[]}`} {
h := newAdmin(t, cfg)
if body := get(h, "/api/overview", "secret").Body.String(); !strings.Contains(body, `"servers":[]`) {
t.Errorf("%s: overview servers not an empty list: %s", cfg, body)
}
if body := get(h, "/api/config", "secret").Body.String(); !strings.Contains(body, `"servers":[]`) {
t.Errorf("%s: config servers not an empty list: %s", cfg, body)
}
}
}
func TestAuthRequired(t *testing.T) {
h := newAdmin(t, `{}`)
for _, p := range []string{"/api/overview", "/api/config", "/metrics"} {
if c := get(h, p, "").Code; c != http.StatusUnauthorized {
t.Errorf("%s without token: %d", p, c)
}
if c := get(h, p, "wrong").Code; c != http.StatusUnauthorized {
t.Errorf("%s with wrong token: %d", p, c)
}
if c := get(h, p, "secret").Code; c != http.StatusOK {
t.Errorf("%s with token: %d", p, c)
}
}
if c := get(h, "/", "").Code; c != http.StatusOK {
t.Errorf("page should load without a token: %d", c)
}
}
+3
View File
@@ -69,6 +69,9 @@ func Parse(data []byte) (*Config, error) {
if c.UnknownMOTD == "" { if c.UnknownMOTD == "" {
c.UnknownMOTD = "§cUnknown server" c.UnknownMOTD = "§cUnknown server"
} }
if c.Servers == nil {
c.Servers = []Server{}
}
return &c, c.validate() return &c, c.validate()
} }
+2 -2
View File
@@ -450,9 +450,9 @@ views.servers = async () => {
S.headerAction = ['Add server', () => serverDialog()]; S.headerAction = ['Add server', () => serverDialog()];
const t = ov.totals; const t = ov.totals;
const totals = h('div', {class:'grid'}, stat('Connected now', t.active), stat('Connections since start', t.total), stat('Turned away', t.rejected), stat('Unknown address', t.unknown_host), stat('Running for', uptime(t.uptime_secs))); const totals = h('div', {class:'grid'}, stat('Connected now', t.active), stat('Connections since start', t.total), stat('Turned away', t.rejected), stat('Unknown address', t.unknown_host), stat('Running for', uptime(t.uptime_secs)));
if (!ov.servers.length) return h('div', {}, totals, emptyState('servers', 'No servers yet', 'Add a server to start routing players. Each one needs an address players type and a backend to send them to.', if (!(ov.servers || []).length) return h('div', {}, totals, emptyState('servers', 'No servers yet', 'Add a server to start routing players. Each one needs an address players type and a backend to send them to.',
h('button', {class:'btn', onclick:() => serverDialog()}, 'Add server'))); h('button', {class:'btn', onclick:() => serverDialog()}, 'Add server')));
const rows = ov.servers.map(s => { const rows = (ov.servers || []).map(s => {
const hl = s.health, st = s.stats || {}; const hl = s.health, st = s.stats || {};
const status = !hl ? badge('Checking') : hl.up ? badge('Online', 'success') : badge('Offline', 'danger'); const status = !hl ? badge('Checking') : hl.up ? badge('Online', 'success') : badge('Offline', 'danger');
const backend = h('td', {}, s.addresses.map(a => h('div', {class:'mono'}, a)), const backend = h('td', {}, s.addresses.map(a => h('div', {class:'mono'}, a)),