Fix the Servers page failing when no servers are configured
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:
1 parent
8f01c6213f
commit
4afbf2d266
4 files changed
+78
-3
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)),
|
||||||
|
|||||||
Reference in new issue
Block a user