fix(groups): Preview shows the draft on screen, not the last saved group
test / go vet + go test + spa build (push) Successful in 16s
release / test gate (push) Successful in 14s
release / binaries + release (push) Successful in 54s
release / docker image (push) Successful in 28s

The Preview button posted the draft the operator was editing and the handler
threw it away — it loaded the group from the store and assembled that. Tighten
a regex, press Preview, and the answer came back byte-identical (and, after the
first call, from cache in under a millisecond), so the button looked dead.
Reproduced in the panel: a filter dropping five countries left all 32 nodes on
screen, unchanged.

  - POST /api/groups/{id}/preview now overlays the posted body on the stored
    group before assembling. The body stays optional, so an empty request still
    means "preview exactly what is stored", and identity fields (id, token,
    created_at) keep their stored values whatever the body claims. Nothing is
    written: previewing must not be a side effect.
  - New POST /api/groups/preview previews a group that does not exist yet. The
    editor used to SAVE an unsaved group just to get an id to preview it, which
    left a half-made group behind whenever someone pressed Preview to look
    around and then cancelled.

Covered by two tests: the draft filter must change the result and must not be
persisted, and previewing a draft must create no group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 18:39:31 +03:00
co-authored by Claude Opus 5
parent a887090e9b
commit 3290d6ed1a
5 changed files with 152 additions and 9 deletions
+84
View File
@@ -723,3 +723,87 @@ func TestCopyDownstreamHeadersDropsTheRightThings(t *testing.T) {
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
// The Preview button posts the draft on the operator's screen. It used to be
// ignored: the handler assembled the STORED group, so tightening a filter and
// pressing Preview returned a byte-identical answer and the button looked dead.
func TestGroupPreviewHonoursTheDraftBody(t *testing.T) {
e := newEnv(t)
panel := newPanel(t, strings.Join([]string{
"vless://11111111-1111-1111-1111-111111111111@nl.example:443#NL Amsterdam",
"vless://22222222-2222-2222-2222-222222222222@de.example:443#DE Frankfurt",
}, "\n"))
s := mustSource(t, e.st, &model.Source{Name: "panel", URL: panel.URL, Enabled: true})
g := &model.Group{
Name: "everything", Enabled: true,
Members: []model.GroupMember{{SourceID: s.ID}},
}
if err := e.st.CreateGroup(g); err != nil {
t.Fatalf("CreateGroup: %v", err)
}
// Stored group, no body: both nodes.
resp := e.do(http.MethodPost, "/api/groups/"+strconv.FormatInt(g.ID, 10)+"/preview", e.auth(), nil)
if body := readBody(t, resp); !strings.Contains(body, "Frankfurt") {
t.Fatalf("the stored group should preview both nodes: %s", body)
}
// Same group, draft body that drops one: the draft must win.
draft := map[string]any{
"name": "everything",
"members": []map[string]any{{"source_id": s.ID}},
"filter": map[string]any{"exclude_name": "Frankfurt"},
"enabled": true,
}
resp = e.do(http.MethodPost, "/api/groups/"+strconv.FormatInt(g.ID, 10)+"/preview", e.auth(), draft)
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("preview with a draft = %d: %s", resp.StatusCode, body)
}
if strings.Contains(body, "Frankfurt") {
t.Errorf("the draft filter was ignored — this is the dead Preview button:\n%s", body)
}
if !strings.Contains(body, "Amsterdam") {
t.Errorf("the draft filter dropped too much:\n%s", body)
}
// Previewing must not write: the stored group still has no filter.
stored, err := e.st.GetGroup(g.ID)
if err != nil {
t.Fatalf("GetGroup: %v", err)
}
if stored.Filter.ExcludeName != "" {
t.Errorf("preview persisted the draft filter: %q", stored.Filter.ExcludeName)
}
}
// A group that does not exist yet can be previewed too, and pressing Preview in
// the editor must not leave a half-made group behind.
func TestGroupDraftPreviewCreatesNothing(t *testing.T) {
e := newEnv(t)
panel := newPanel(t, "vless://11111111-1111-1111-1111-111111111111@nl.example:443#NL Amsterdam")
s := mustSource(t, e.st, &model.Source{Name: "panel", URL: panel.URL, Enabled: true})
draft := map[string]any{
"name": "unsaved",
"members": []map[string]any{{"source_id": s.ID}},
"enabled": true,
}
resp := e.do(http.MethodPost, "/api/groups/preview", e.auth(), draft)
body := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("draft preview = %d: %s", resp.StatusCode, body)
}
if !strings.Contains(body, "Amsterdam") {
t.Errorf("draft preview returned no nodes: %s", body)
}
groups, err := e.st.ListGroups()
if err != nil {
t.Fatalf("ListGroups: %v", err)
}
if len(groups) != 0 {
t.Errorf("previewing a draft created %d group(s)", len(groups))
}
}
+55
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
@@ -99,12 +100,66 @@ type previewResponse struct {
// previewGroup runs the real assembly pipeline so what the operator sees is
// what a client would get, minus the rendering step.
//
// The posted body is the DRAFT on the operator's screen and it wins over the
// stored row. Without that the button is a lie: you tighten a regex, press
// Preview, and get a byte-identical answer assembled from the group as it was
// last saved. Nothing is written here — previewing must never be a side effect.
func (a *api) previewGroup(w http.ResponseWriter, r *http.Request) {
g, ok := a.loadGroup(w, r)
if !ok {
return
}
if !a.applyDraft(w, r, g) {
return
}
a.respondPreview(w, r, g)
}
// previewDraftGroup previews a group that does not exist yet, so the editor can
// show a merged list before anything is created. Nothing is stored.
func (a *api) previewDraftGroup(w http.ResponseWriter, r *http.Request) {
g := &model.Group{Enabled: true}
if err := decodeJSON(r, g); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
// A draft has no identity: an id or token in the body is meaningless here
// and must not make this look like it touched a stored row.
g.ID, g.Token = 0, ""
normalizeGroup(g)
if err := a.validateGroup(*g); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
a.respondPreview(w, r, g)
}
// applyDraft overlays an optional request body on a loaded group. An empty body
// is valid and means "preview exactly what is stored". The identity fields stay
// the stored ones whatever the body claims.
func (a *api) applyDraft(w http.ResponseWriter, r *http.Request, g *model.Group) bool {
if r.Body == nil || r.ContentLength == 0 {
return true
}
id, token, createdAt := g.ID, g.Token, g.CreatedAt
if err := decodeJSON(r, g); err != nil {
if errors.Is(err, io.EOF) { // a body that turned out to be empty
return true
}
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
g.ID, g.Token, g.CreatedAt = id, token, createdAt
normalizeGroup(g)
if err := a.validateGroup(*g); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return false
}
return true
}
func (a *api) respondPreview(w http.ResponseWriter, r *http.Request, g *model.Group) {
ctx, cancel := context.WithTimeout(r.Context(), a.upstreamTimeout()+5*time.Second)
defer cancel()
+3
View File
@@ -93,6 +93,9 @@ func Router(deps Deps) http.Handler {
r.Get("/{id}", a.getGroup)
r.Put("/{id}", a.updateGroup)
r.Delete("/{id}", a.deleteGroup)
// Draft preview: no id, nothing stored. Lets the editor show what a
// group WOULD produce before it exists.
r.Post("/preview", a.previewDraftGroup)
r.Post("/{id}/preview", a.previewGroup)
})
+5
View File
@@ -161,6 +161,11 @@ export const api = {
previewGroup: (id: number, draft?: Partial<Group>) =>
request<PreviewResult>('POST', `/api/groups/${id}/preview`, draft ?? {}),
// Preview a group that does not exist yet. Nothing is stored, so opening the
// editor and pressing Preview never leaves a half-made group behind.
previewDraftGroup: (draft: Partial<Group>) =>
request<PreviewResult>('POST', '/api/groups/preview', draft),
// ------------------------------------------------------------- clients
listClients: async (q: ClientQuery): Promise<Page<Client>> => {
+5 -9
View File
@@ -368,15 +368,11 @@ function GroupDrawer({
setPreviewing(true)
setPreviewError(null)
try {
// A group must exist before it can be previewed; save the draft first so
// the preview always reflects what is on screen.
let id = form.id
if (isNew) {
const saved = await save()
if (!saved) return
id = saved.id
}
const res = await api.previewGroup(id, payload())
// The draft on screen is what gets previewed, saved or not — a new group
// goes to the id-less route so pressing Preview never creates anything.
const res = isNew
? await api.previewDraftGroup(payload())
: await api.previewGroup(form.id, payload())
setPreview(res)
} catch (e) {
setPreview(null)