diff --git a/agent/internal/logs/history_linux.go b/agent/internal/logs/history_linux.go new file mode 100644 index 000000000..1e2f41e70 --- /dev/null +++ b/agent/internal/logs/history_linux.go @@ -0,0 +1,164 @@ +package logs + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "syscall" + "unicode/utf8" +) + +const historyBytes = 64 << 10 + +var errHistoryChanged = errors.New("log history changed") +var errInvalidCursor = errors.New("invalid history cursor") + +type historyPage struct { + Text string `json:"text"` + Before string `json:"before,omitempty"` +} + +// Authenticate the entire byte window the next request may return. A repeated +// line near the boundary alone cannot identify a file generation. +func fileCursor(identity *syscall.Stat_t, offset int64, window []byte) string { + hash := sha256.Sum256(window) + return fmt.Sprintf("%x.%x.%x.%s", identity.Dev, identity.Ino, offset, hex.EncodeToString(hash[:16])) +} + +func readHistoryWindow(f io.ReaderAt, end int64) ([]byte, error) { + start := max(end-historyBytes, 0) + buf := make([]byte, end-start) + if _, err := f.ReadAt(buf, start); err != nil { + if errors.Is(err, io.EOF) { + return nil, errHistoryChanged + } + return nil, fmt.Errorf("read log page: %w", err) + } + return buf, nil +} + +type historyFile interface { + io.ReaderAt + Stat() (os.FileInfo, error) +} + +func filePage(f historyFile, before string, lines int) (historyPage, error) { + info, err := f.Stat() + if err != nil { + return historyPage{}, fmt.Errorf("stat log: %w", err) + } + identity, ok := info.Sys().(*syscall.Stat_t) + if !ok || identity == nil { + return historyPage{}, errors.New("log file has no device or inode information") + } + end := info.Size() + if before != "" { + if len(before) > 128 { + return historyPage{}, errInvalidCursor + } + parts := strings.SplitN(before, ".", 4) + if len(parts) != 4 { + return historyPage{}, errInvalidCursor + } + end, err = strconv.ParseInt(parts[2], 16, 64) + if err != nil || end <= 0 { + return historyPage{}, errInvalidCursor + } + if end > info.Size() { + return historyPage{}, errHistoryChanged + } + } + start := max(end-historyBytes, 0) + buf, err := readHistoryWindow(f, end) + if err != nil { + if before != "" { + return historyPage{}, errHistoryChanged + } + return historyPage{}, err + } + if before != "" && before != fileCursor(identity, end, buf) { + return historyPage{}, errHistoryChanged + } + search := bytes.TrimSuffix(buf, []byte{'\n'}) + at := len(search) + for i := 0; i < lines; i++ { + at = bytes.LastIndexByte(search[:at], '\n') + if at < 0 { + break + } + } + cut := at + 1 + if start > 0 { + // A byte-bounded page can divide a long line. Keep valid UTF-8 intact; + // concatenating adjacent page text reconstructs it without synthetic LFs. + for cut < len(buf) && cut < 4 && !utf8.RuneStart(buf[cut]) { + cut++ + } + } + page := historyPage{Text: strings.ToValidUTF8(string(buf[cut:]), "?")} + if next := start + int64(cut); next > 0 { + window, err := readHistoryWindow(f, next) + if err != nil { + return historyPage{}, err + } + page.Before = fileCursor(identity, next, window) + // Bind the outgoing cursor and returned text to the same observed + // bytes even when a writer truncates and regrows between our reads. + current, err := readHistoryWindow(f, end) + if err != nil { + return historyPage{}, err + } + if !bytes.Equal(buf, current) { + return historyPage{}, errHistoryChanged + } + } + return page, nil +} + +func (h *handler) history(w http.ResponseWriter, req *http.Request) { + if h.path == "" { + http.Error(w, "log history unavailable", http.StatusServiceUnavailable) + return + } + source, err := h.openSource() + if err != nil { + http.Error(w, "log file unavailable", http.StatusInternalServerError) + return + } + defer source.close() + f, err := source.open() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if req.URL.Query().Get("before") != "" { + http.Error(w, errHistoryChanged.Error(), http.StatusConflict) + return + } + http.Error(w, "log file unavailable", http.StatusNotFound) + return + } + http.Error(w, "log file unavailable", http.StatusInternalServerError) + return + } + defer func() { _ = f.Close() }() + page, err := filePage(f, req.URL.Query().Get("before"), 100) + switch { + case errors.Is(err, errInvalidCursor): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, errHistoryChanged): + http.Error(w, err.Error(), http.StatusConflict) + case err != nil: + http.Error(w, "log file unavailable", http.StatusInternalServerError) + default: + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(page) + } +} diff --git a/agent/internal/logs/history_test.go b/agent/internal/logs/history_test.go new file mode 100644 index 000000000..76190afdd --- /dev/null +++ b/agent/internal/logs/history_test.go @@ -0,0 +1,325 @@ +package logs + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/coder/websocket" +) + +func TestHistoryEndpoint(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "server.log") + if err := os.WriteFile(path, []byte(strings.Repeat("line\n", 110)), 0600); err != nil { + t.Fatal(err) + } + base := mountServer(t, dir, path) + request := func(endpoint, cursor string, status int) historyPage { + t.Helper() + resp, err := testGet(t, endpoint+"/logs/history?before="+url.QueryEscape(cursor)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != status { + t.Fatalf("status=%d want=%d", resp.StatusCode, status) + } + var page historyPage + if status == http.StatusOK { + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + t.Fatal(err) + } + } + return page + } + page := request(base, "", 200) + if strings.Count(page.Text, "\n") != 100 || page.Before == "" { + t.Fatalf("page=%+v", page) + } + older := request(base, page.Before, 200) + if strings.Count(older.Text, "\n") != 10 || older.Before != "" { + t.Fatalf("older=%+v", older) + } + request(base, "invalid", 400) + request(base, strings.Repeat(".", 129), 400) + request(base, "0.0.x.x", 400) + request(base, "0.0.7fffffff.x", 409) + if err := os.Rename(path, path+".1"); err != nil { + t.Fatal(err) + } + request(base, page.Before, 409) + if err := os.WriteFile(path, []byte(strings.Repeat("line\n", 110)), 0600); err != nil { + t.Fatal(err) + } + request(base, page.Before, 409) + request(mountServer(t, dir, ""), "", 503) + request(mountServer(t, dir, filepath.Join(dir, "absent")), "", 404) + request(mountServer(t, dir, dir), "", 500) + if err := os.Mkdir(filepath.Join(dir, "logs"), 0700); err != nil { + t.Fatal(err) + } + request(mountServer(t, dir, filepath.Join(dir, "logs")), "", 500) +} + +func TestHistoryRejectsMissingFileIdentity(t *testing.T) { + f, err := (fstest.MapFS{"server.log": {}}).Open("server.log") + if err != nil { + t.Fatal(err) + } + defer f.Close() + source, ok := f.(historyFile) + if !ok { + t.Fatal("test file does not support reading at an offset") + } + if _, err := filePage(source, "", 1); err == nil { + t.Fatal("served a file without a device and inode") + } +} + +func TestHistoryReportsUnreadableFile(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "log") + if err != nil { + t.Fatal(err) + } + if _, err = f.WriteString("one\ntwo\n"); err != nil { + t.Fatal(err) + } + page, err := filePage(f, "", 1) + if err != nil { + t.Fatal(err) + } + writeOnly, err := os.OpenFile(f.Name(), os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer writeOnly.Close() + if _, err = filePage(writeOnly, "", 1); err == nil { + t.Fatal("write-only file was readable") + } + if _, err = filePage(writeOnly, page.Before, 1); !errors.Is(err, errHistoryChanged) { + t.Fatalf("lost cursor: %v", err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + if _, err = filePage(f, "", 100); err == nil { + t.Fatal("closed file unexpectedly readable") + } +} + +func TestHistoryTracksFileAcrossAppends(t *testing.T) { + p := filepath.Join(t.TempDir(), "game.log") + if err := os.WriteFile(p, []byte("one\ntwo\nthree\nfour\n"), 0600); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(p, os.O_RDWR|os.O_APPEND, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + latest, err := filePage(f, "", 2) + if err != nil || latest.Text != "three\nfour\n" || latest.Before == "" { + t.Fatalf("latest=%+v err=%v", latest, err) + } + if _, err = f.WriteString("five\n"); err != nil { + t.Fatal(err) + } + older, err := filePage(f, latest.Before, 2) + if err != nil || older.Text != "one\ntwo\n" || older.Before != "" { + t.Fatalf("older=%+v err=%v", older, err) + } + replacement, err := os.CreateTemp(t.TempDir(), "other") + if err != nil { + t.Fatal(err) + } + defer replacement.Close() + if _, err = filePage(replacement, latest.Before, 2); !errors.Is(err, errHistoryChanged) { + t.Fatalf("replacement: %v", err) + } + if err = f.Truncate(0); err != nil { + t.Fatal(err) + } + if _, err = f.WriteString("new\nnew\nnew\n"); err != nil { + t.Fatal(err) + } + if _, err = filePage(f, latest.Before, 2); !errors.Is(err, errHistoryChanged) { + t.Fatalf("truncate and regrow: %v", err) + } +} + +func TestHistoryBoundsLongFileLines(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "log") + if err != nil { + t.Fatal(err) + } + defer f.Close() + line := strings.Repeat("ä", historyBytes) + "\n" + if _, err = f.WriteString(line); err != nil { + t.Fatal(err) + } + var before, joined string + for i := 0; i < 5; i++ { + page, err := filePage(f, before, 2) + if err != nil || len(page.Text) > historyBytes { + t.Fatalf("bytes=%d err=%v", len(page.Text), err) + } + joined = page.Text + joined + before = page.Before + if before == "" { + break + } + } + if joined != line { + t.Fatal("long line changed across page boundaries") + } +} + +func TestHistoryRejectsRegrowthWithSameAdjacentLines(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "log") + if err != nil { + t.Fatal(err) + } + defer f.Close() + tail := strings.Repeat("same status line\n", 16) + if _, err := f.WriteString("first-run\n" + tail); err != nil { + t.Fatal(err) + } + page, err := filePage(f, "", 2) + if err != nil || page.Before == "" { + t.Fatalf("first: %+v %v", page, err) + } + if err := f.Truncate(0); err != nil { + t.Fatal(err) + } + if _, err := f.WriteAt([]byte("other-run\n"+tail), 0); err != nil { + t.Fatal(err) + } + if _, err := filePage(f, page.Before, 2); !errors.Is(err, errHistoryChanged) { + t.Fatalf("accepted a different preceding page after copytruncate: %v", err) + } +} + +func TestLongLiveRecordLeavesTheNextRecordReadable(t *testing.T) { + reader := bufio.NewReader(strings.NewReader(strings.Repeat("x", 2*historyBytes) + "\nnext\n")) + line, err := readLogLine(t.Context(), reader) + if err != nil || len(line) > 32<<10 || !strings.Contains(line, "truncated") { + t.Fatalf("long record: %d bytes, %v", len(line), err) + } + line, err = readLogLine(t.Context(), reader) + if err != nil || line != "next\n" { + t.Fatalf("next record: %q %v", line, err) + } +} + +func TestReplaySkipsHugePartialRecordAndContinuesLive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "game.log") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := file.Truncate(maxTailBytes + 2048); err != nil { + t.Fatal(err) + } + if _, err := file.Seek(0, io.SeekEnd); err != nil { + t.Fatal(err) + } + if _, err := file.WriteString("\nrecent\n"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + endpoint := "ws" + strings.TrimPrefix(mountServer(t, dir, path), "http") + "/logs/tail?tail=10" + conn, resp, err := websocket.Dial(ctx, endpoint, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + t.Fatal(err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + if _, msg, err := conn.Read(ctx); err != nil || string(msg) != "recent\n" { + t.Fatalf("replay at bounded snapshot: %d bytes, %v", len(msg), err) + } + if _, err := file.WriteString(strings.Repeat("z", historyBytes) + "\nnext\n"); err != nil { + t.Fatal(err) + } + if _, msg, err := conn.Read(ctx); err != nil || !strings.Contains(string(msg), "[log line truncated]") { + t.Fatalf("oversized live frame: %d bytes, %v", len(msg), err) + } + if _, msg, err := conn.Read(ctx); err != nil || string(msg) != "next\n" { + t.Fatalf("next frame: %q, %v", msg, err) + } +} + +// A rewrite between the main read and either cursor read must never mix two +// generations in one successful response. +type changingHistoryFile struct { + *os.File + onRead func(int) + reads int +} + +func (f *changingHistoryFile) ReadAt(p []byte, off int64) (int, error) { + f.reads++ + f.onRead(f.reads) + return f.File.ReadAt(p, off) +} + +func TestHistoryRejectsRewriteDuringRead(t *testing.T) { + for _, tc := range []struct { + name string + at int + regrow bool + }{ + {"before-page", 1, false}, {"before-next-cursor", 2, false}, + {"before-recheck", 3, false}, {"regrown-before-recheck", 3, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "log") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString("one\ntwo\nthree\n"); err != nil { + t.Fatal(err) + } + changed := &changingHistoryFile{File: f, onRead: func(n int) { + if n != tc.at { + return + } + if err := f.Truncate(0); err != nil { + t.Fatal(err) + } + if tc.regrow { + if _, err := f.WriteAt([]byte("new\nnew\nother\n"), 0); err != nil { + t.Fatal(err) + } + } + }} + if _, err := filePage(changed, "", 1); !errors.Is(err, errHistoryChanged) { + t.Fatalf("mixed source accepted: %v", err) + } + }) + } +} + +func TestCanceledLiveRecordRead(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := readLogLine(ctx, bufio.NewReader(strings.NewReader("hello"))); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled read: %v", err) + } +} diff --git a/agent/internal/logs/logs.go b/agent/internal/logs/logs.go index 3903ccfb2..2d36cf215 100644 --- a/agent/internal/logs/logs.go +++ b/agent/internal/logs/logs.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/coder/websocket" @@ -32,6 +33,7 @@ func Mount(r chi.Router, root, path string) { h := newHandler(root, path) r.Get("/logs/tail", h.tail) r.Get("/logs/download", h.download) + r.Get("/logs/history", h.history) } func (h *handler) download(w http.ResponseWriter, req *http.Request) { @@ -169,13 +171,63 @@ func appendTail(buf []string, total int64, line string, maxLines, maxBytes int64 return buf, total } -// replayTail reads all lines from file f and sends the last tailLines to conn. +// readLogLine discards excess bytes in fixed-size fragments while keeping a +// readable prefix. A writer can emit arbitrarily large unterminated records. +func readLogLine(ctx context.Context, reader *bufio.Reader) (string, error) { + // The local API proxy and remote gateway use coder/websocket's + // default incoming message limit. Keep each live frame within it. + const frameBytes = 32 << 10 + const marker = "... [log line truncated]\n" + var buf []byte + clipped := false + for { + if err := ctx.Err(); err != nil { + return "", err + } + fragment, err := reader.ReadSlice('\n') + space := frameBytes - len(buf) + if len(fragment) > space { + clipped = true + } + buf = append(buf, fragment[:min(len(fragment), space)]...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if clipped { + return strings.ToValidUTF8(string(buf[:frameBytes-len(marker)]), "?") + marker, err + } + return string(buf), err + } +} + +// replayTail reads a bounded snapshot from EOF and hands subsequent appends +// to the live reader at exactly that position. func replayTail(ctx context.Context, conn *websocket.Conn, f *os.File, tailLines int64) error { - reader := bufio.NewReader(f) + end, err := f.Seek(0, io.SeekEnd) + if err != nil { + return err + } + start := max(int64(0), end-maxTailBytes) + // Inspect one preceding byte so a cut exactly at a line boundary can + // retain the complete first record. SectionReader leaves f at the live + // handoff position even when the file grows during replay. + readStart := max(int64(0), start-1) + reader := bufio.NewReader(io.NewSectionReader(f, readStart, end-readStart)) + if readStart < start { + previous, err := reader.ReadByte() + if err != nil { + return err + } + if previous != '\n' { + if _, err := readLogLine(ctx, reader); err != nil && !errors.Is(err, io.EOF) { + return err + } + } + } var buffer []string var totalBytes int64 for { - line, err := reader.ReadString('\n') + line, err := readLogLine(ctx, reader) buffer, totalBytes = appendTail(buffer, totalBytes, line, tailLines, maxTailBytes) if err != nil { if errors.Is(err, io.EOF) { @@ -199,7 +251,7 @@ func tailLoop(ctx context.Context, conn *websocket.Conn, source *logSource, f *o if ctx.Err() != nil { return ctx.Err() } - line, err := reader.ReadString('\n') + line, err := readLogLine(ctx, reader) if len(line) > 0 { if werr := conn.Write(ctx, websocket.MessageText, []byte(line)); werr != nil { return werr @@ -240,7 +292,11 @@ func checkRotation(source *logSource, f *os.File) (bool, error) { if err != nil { return false, err } - return !os.SameFile(fi1, fi2), nil + if !os.SameFile(fi1, fi2) { + return true, nil + } + offset, err := f.Seek(0, io.SeekCurrent) + return offset > fi2.Size(), err } func sleep(ctx context.Context, d time.Duration) error { diff --git a/agent/openapi.yaml b/agent/openapi.yaml index 49b94061e..6c1883eaa 100644 --- a/agent/openapi.yaml +++ b/agent/openapi.yaml @@ -234,6 +234,19 @@ paths: "204": { description: OK } "400": { description: Refusing to delete root or invalid request } + /logs/history: + get: + summary: Read the preceding page of the configured log file + parameters: + - in: query + name: before + schema: { type: string, maxLength: 128 } + responses: + "200": + description: Up to 100 lines and 64 KiB of text, with an optional cursor for the preceding page + "400": { description: Invalid cursor } + "404": { description: File unavailable } + "409": { description: File replaced or truncated } /logs/tail: get: summary: Tail the game container log over WebSocket (text frames per line) diff --git a/agent/specs.md b/agent/specs.md index 40935f565..7b0df12f1 100644 --- a/agent/specs.md +++ b/agent/specs.md @@ -198,6 +198,7 @@ Resource usage env vars (set by the operator): | `/mods` | DELETE | Uninstall a mod; query param `?name=` (not a JSON body) | | `/mods/upload` | POST | Upload a mod archive; body is multipart/form-data | | `/logs/download` | GET | Download the full game log file as an attachment | +| `/logs/history` | GET | Read back from EOF or the opaque `before` cursor. Each page returns up to 100 lines and 64 KiB of text. A long line may span pages. The cursor binds to the file identity, byte offset and full preceding 64 KiB window. A different inode, missing range or changed window returns 409. An in-place rewrite that leaves the observed bytes identical is indistinguishable from an append. Live records over 32 KiB carry a truncation marker; initial live replay reads the last 4 MiB. | | `/players/whitelist` | GET | Currently whitelisted players; response: bare JSON array of names (`[]` if whitelist management isn't supported or RCON is disabled) | | `/players/whitelist/add` | POST | Add a player to the whitelist; request: `{ name }`; response: `{ ok, raw? }` | | `/players/whitelist/remove` | POST | Remove a player from the whitelist; request: `{ name }`; response: `{ ok, raw? }` | diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml index 89fb097f3..b03748ccf 100644 --- a/api/docs/openapi.yaml +++ b/api/docs/openapi.yaml @@ -404,6 +404,24 @@ paths: responses: '200': description: OK + '/servers/{name}/logs/history': + get: + summary: Read game-file history through the selected server's agent + parameters: + - { in: path, name: name, required: true, schema: { type: string } } + - { in: query, name: before, schema: { type: string } } + responses: + '200': { description: Log text and optional preceding-page cursor } + '409': { description: File replaced or truncated } + '/servers/{name}/logs/pod/history': + get: + summary: Read earlier output of an authorized game or init container + parameters: + - { in: path, name: name, required: true, schema: { type: string } } + - { in: query, name: before, schema: { type: string } } + responses: + '200': { description: 'Log text, source container, optional before cursor and limited flag' } + '409': { description: Container replaced or requested boundary unavailable } '/servers/{name}/modpack': post: responses: diff --git a/api/internal/gatewayprotocol/protocol.go b/api/internal/gatewayprotocol/protocol.go index 584f2d41d..3b1cb7f25 100644 --- a/api/internal/gatewayprotocol/protocol.go +++ b/api/internal/gatewayprotocol/protocol.go @@ -53,7 +53,7 @@ func Allowed(method, path string) (int64, bool) { switch method { case http.MethodGet: switch path { - case "/console", "/logs/tail", "/logs/download", "/files/list", "/files/read", "/files/download", "/players", "/players/banned", "/players/whitelist", "/status", "/mods": + case "/console", "/logs/tail", "/logs/download", "/logs/history", "/files/list", "/files/read", "/files/download", "/players", "/players/banned", "/players/whitelist", "/status", "/mods": return 0, true } case http.MethodPost: diff --git a/api/internal/handlers/cluster_guard_test.go b/api/internal/handlers/cluster_guard_test.go index 2add12d18..981e41803 100644 --- a/api/internal/handlers/cluster_guard_test.go +++ b/api/internal/handlers/cluster_guard_test.go @@ -324,13 +324,13 @@ func TestHomeClientMounts_ServeHomeClusterOnly(t *testing.T) { if strings.HasPrefix(path, "/servers/") || strings.HasPrefix(path, "/ws/servers/") { want = http.StatusNotImplemented } - attach := path == "/ws/servers/alpha/console-pty" + selectedPodRoute := path == "/ws/servers/alpha/console-pty" || path == "/servers/alpha/logs/pod/history" // Only the registry and ID-list handlers above use the selected client. // ws.Mount has no gateway options here, so install/upload keep their // legacy 501 guard rather than performing a remote target lookup. selectedModRoute := strings.HasPrefix(path, "/servers/alpha/mods/registry/") || path == "/servers/alpha/mods/ids" || path == "/servers/alpha/modpack" - if attach || selectedModRoute { + if selectedPodRoute || selectedModRoute { // The registered remote cluster has no alpha GameServer. want = http.StatusNotFound } else if path == "/ws/servers/alpha/logs/pod" { @@ -365,7 +365,7 @@ func TestHomeClientMounts_ServeHomeClusterOnly(t *testing.T) { if n := kubeClientCalls(t, home) - before; n != wantHomeCalls { t.Errorf("%s %s?cluster=%s: home client saw %d API calls, want %d", method, path, remote, n, wantHomeCalls) } - if (attach || selectedModRoute) && kubeClientCalls(t, remoteClient) == remoteBefore { + if (selectedPodRoute || selectedModRoute) && kubeClientCalls(t, remoteClient) == remoteBefore { t.Errorf("%s %s?cluster=%s: remote client saw no lookup", method, path, remote) } return nil diff --git a/api/internal/ws/cluster_guard_test.go b/api/internal/ws/cluster_guard_test.go index a769488d1..a538fae07 100644 --- a/api/internal/ws/cluster_guard_test.go +++ b/api/internal/ws/cluster_guard_test.go @@ -61,6 +61,7 @@ func TestMount_RejectsNonLocalCluster(t *testing.T) { {"GET", "/ws/servers/alpha/console"}, {"GET", "/ws/servers/alpha/logs"}, {"GET", "/servers/alpha/logs/download"}, + {"GET", "/servers/alpha/logs/history"}, {"GET", "/servers/alpha/files/list"}, {"POST", "/servers/alpha/actions/run"}, {"GET", "/servers/alpha/status"}, diff --git a/api/internal/ws/dialer.go b/api/internal/ws/dialer.go index 662593f3a..319d7016b 100644 --- a/api/internal/ws/dialer.go +++ b/api/internal/ws/dialer.go @@ -53,6 +53,7 @@ func MountWithPlayerNames(r chi.Router, reg *kube.Registry, caBundle, clientCert r.Get("/ws/servers/{name}/console", p.agentWS("/console")) r.Get("/ws/servers/{name}/logs", p.agentWS("/logs/tail")) r.Get("/servers/{name}/logs/download", p.agentHTTP("/logs/download")) + r.Get("/servers/{name}/logs/history", p.agentHTTP("/logs/history")) // PTY console attaches via the Kubernetes API (not the agent), so it // doesn't need agent mTLS material — it uses the selected cluster's // kubeconfig. Mounted unconditionally. diff --git a/api/internal/ws/multicluster_test.go b/api/internal/ws/multicluster_test.go index f3242112a..c85f365d4 100644 --- a/api/internal/ws/multicluster_test.go +++ b/api/internal/ws/multicluster_test.go @@ -40,7 +40,11 @@ func mockStreamCluster(t *testing.T, id string) (*kube.Client, *atomic.Int64, *a } switch { case strings.HasSuffix(r.URL.Path, "/log"): - _, _ = w.Write([]byte(id + " logs\n")) + if r.URL.Query().Get("timestamps") == "true" { + _, _ = w.Write([]byte("2026-01-01T00:00:00Z " + id + " logs\n")) + } else { + _, _ = w.Write([]byte(id + " logs\n")) + } case strings.HasSuffix(r.URL.Path, "/attach"): attaches.Add(1) if r.Method != http.MethodPost || r.URL.Query().Get("container") != "game" { @@ -237,3 +241,36 @@ func TestPodLogs_StopsWhenPodIsReplaced(t *testing.T) { t.Fatal("replacement pod accepted by old stream") } } + +func TestPodHistoryRemoteScope(t *testing.T) { + local, localHits, _ := mockStreamCluster(t, "local") + remote, _, _ := mockStreamCluster(t, "remote") + reg := kube.NewRegistry("local") + reg.Set("local", local) + reg.Set("remote", remote) + router := streamRouter(reg, remoteStreamUser()) + for _, tc := range []struct { + selector string + status int + }{ + {"cluster=remote", http.StatusOK}, + {"cluster=local", http.StatusForbidden}, + {"cluster=unknown", http.StatusBadRequest}, + {"cluster=remote&namespace=not-allowed", http.StatusBadRequest}, + } { + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/servers/alpha/logs/pod/history?"+tc.selector, nil)) + if rr.Code != tc.status { + t.Fatalf("%s: %d %s", tc.selector, rr.Code, rr.Body.String()) + } + if tc.status == http.StatusOK { + var page podHistoryPage + if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil || page.Text != "remote logs\n" { + t.Fatalf("unexpected remote page: %+v, %v", page, err) + } + } + } + if localHits.Load() != 0 { + t.Fatal("remote caller reached the local cluster") + } +} diff --git a/api/internal/ws/pod_history.go b/api/internal/ws/pod_history.go new file mode 100644 index 000000000..c62fcf69f --- /dev/null +++ b/api/internal/ws/pod_history.go @@ -0,0 +1,338 @@ +package ws + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + + "github.com/GameplanePanel/gameplane/api/internal/httperr" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/scope" +) + +// Kubelet exposes a line tail and a lower time bound, but no reverse seek. +// Anchor pages to an exact timestamped record. A missing or ambiguous anchor +// is a discontinuity, never permission to substitute a different page. +const ( + podHistoryLines = 100 + podHistoryBytes = 64 << 10 + podHistoryReadBytes = 1 << 20 + podHistoryMaxTail = 20000 +) + +var errPodHistoryChanged = errors.New("pod log history changed") + +type podHistoryCursor struct { + Pod string `json:"p"` + Container string `json:"c"` + ID string `json:"i"` + Restarts int32 `json:"r"` + Anchor string `json:"a,omitempty"` + Depth int64 `json:"d,omitempty"` + Since int64 `json:"s,omitempty"` +} + +func (c podHistoryCursor) encode() string { + data, _ := json.Marshal(c) + return base64.RawURLEncoding.EncodeToString(data) +} + +type podHistoryPage struct { + Text string `json:"text"` + Source string `json:"source,omitempty"` + Before string `json:"before,omitempty"` + Limited bool `json:"limited,omitempty"` +} + +func podRecordID(line string) string { + digest := sha256.Sum256([]byte(line)) + return hex.EncodeToString(digest[:16]) +} + +// Locate the unique record including the kubelet timestamp so repeated game +// messages remain distinct. An ambiguous marker cannot be replayed safely. +func podBoundary(rows []string, anchor string) (int, error) { + if anchor == "" { + return len(rows), nil + } + at := -1 + for i, row := range rows { + if podRecordID(row) != anchor { + continue + } + if at >= 0 { + return 0, errPodHistoryChanged + } + at = i + } + if at < 0 { + return 0, errPodHistoryChanged + } + return at, nil +} + +func podPageRows(rows []string, end, limit int) ([]string, int, bool) { + var bytesUsed int + start := end + for start > 0 && end-start < limit { + _, msg, _ := strings.Cut(rows[start-1], " ") + if bytesUsed+len(msg)+1 > podHistoryBytes { + break + } + bytesUsed += len(msg) + 1 + start-- + } + limited := start == end && end > 0 + if limited { + start-- + } + lines := make([]string, 0, end-start) + for _, row := range rows[start:end] { + _, msg, _ := strings.Cut(row, " ") + if limited { + msg = strings.ToValidUTF8(msg[:min(len(msg), podHistoryBytes-8)], "") + "…" + } + lines = append(lines, strings.ToValidUTF8(msg, "?")) + } + return lines, start, limited +} + +func historyContainers(pod *corev1.Pod) []corev1.ContainerStatus { + states := make(map[string]corev1.ContainerStatus) + for _, s := range pod.Status.InitContainerStatuses { + states[s.Name] = s + } + for _, s := range pod.Status.ContainerStatuses { + states[s.Name] = s + } + var ordered []corev1.ContainerStatus + appendStarted := func(name string) { + s := states[name] + if s.State.Running != nil || s.State.Terminated != nil { + ordered = append(ordered, s) + } + } + for _, c := range pod.Spec.InitContainers { + appendStarted(c.Name) + } + appendStarted(gameContainer) + return ordered +} + +func historyIdentity(pod *corev1.Pod, s corev1.ContainerStatus) podHistoryCursor { + return podHistoryCursor{Pod: string(pod.UID), Container: s.Name, ID: s.ContainerID, Restarts: s.RestartCount} +} + +func sameHistoryContainer(c podHistoryCursor, pod *corev1.Pod, s corev1.ContainerStatus) bool { + return c.Pod == string(pod.UID) && c.Container == s.Name && c.ID == s.ContainerID && c.Restarts == s.RestartCount +} + +func mountPodHistory(r chi.Router, reg *kube.Registry) { + r.Get("/servers/{name}/logs/pod/history", func(w http.ResponseWriter, req *http.Request) { + k, ok := streamClient(w, req, reg) + if !ok { + return + } + ns, err := scope.Resolve(req) + if err != nil { + httperr.Write(w, req, err) + return + } + name := chi.URLParam(req, "name") + if len(validation.IsDNS1123Subdomain(name)) != 0 { + http.Error(w, "invalid server name", http.StatusBadRequest) + return + } + var cursor podHistoryCursor + raw := req.URL.Query().Get("before") + if raw != "" { + if len(raw) > 2048 { + http.Error(w, "invalid history cursor", http.StatusBadRequest) + return + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil || json.Unmarshal(data, &cursor) != nil || cursor.Pod == "" || cursor.Container == "" || cursor.Depth < 0 || cursor.Depth > podHistoryMaxTail { + http.Error(w, "invalid history cursor", http.StatusBadRequest) + return + } + } + pod, err := serverPod(req.Context(), k, ns, name) + if err != nil { + httperr.Write(w, req, err) + return + } + h := podLogProxy{k: k, podUID: pod.UID} + page, err := h.historyPage(req.Context(), ns, pod, cursor) + if errors.Is(err, errPodHistoryChanged) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + if err != nil { + httperr.Write(w, req, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(page) + }) +} + +func (h *podLogProxy) historyPage(ctx context.Context, ns string, pod *corev1.Pod, cursor podHistoryCursor) (podHistoryPage, error) { + steps := historyContainers(pod) + index := len(steps) - 1 + if cursor.Pod != "" { + for index >= 0 && !sameHistoryContainer(cursor, pod, steps[index]) { + index-- + } + if index < 0 { + return podHistoryPage{}, errPodHistoryChanged + } + } + if index < 0 { + return podHistoryPage{}, nil + } + state := steps[index] + identity := historyIdentity(pod, state) + // Leave room for writes between requests. The next page still starts at + // the exact boundary even if records were appended while it was in flight. + tail := min(int64(podHistoryMaxTail), cursor.Depth+podHistoryLines+64) + opts := &corev1.PodLogOptions{Container: state.Name, Timestamps: true, TailLines: &tail} + rows, capped, err := h.historyWindow(ctx, ns, pod.Name, identity, opts) + if err != nil { + return podHistoryPage{}, err + } + limitedWindow := capped + end, boundaryErr := podBoundary(rows, cursor.Anchor) + // Kubelet's byte cap cuts the end of the requested tail. Reduce the + // excess line count to recover recent complete records from chatty logs. + // Keep enough depth for an existing anchor, then try its time bound. + for i := 0; i < 4 && capped && (cursor.Anchor == "" || boundaryErr != nil) && tail > cursor.Depth+1; i++ { + tail = cursor.Depth + max(int64(1), (tail-cursor.Depth)/4) + rows, capped, err = h.historyWindow(ctx, ns, pod.Name, identity, opts) + if err != nil { + return podHistoryPage{}, err + } + end, boundaryErr = podBoundary(rows, cursor.Anchor) + } + // A burst can move the record outside the line tail. Read again from a + // timestamp captured in the original page. Appends after the anchor cannot + // move this lower bound. Both reads have independent hard byte caps. + usedSince := false + if cursor.Anchor != "" && cursor.Since > 0 && boundaryErr != nil { + since := metav1.NewTime(time.Unix(cursor.Since, 0).UTC()) + opts.TailLines = nil + opts.SinceTime = &since + rows, capped, err = h.historyWindow(ctx, ns, pod.Name, identity, opts) + if err != nil { + return podHistoryPage{}, err + } + end, boundaryErr = podBoundary(rows, cursor.Anchor) + usedSince = true + } + // A same-name recreation or a container restart during the read must + // never return bytes as belonging to the previously authorized instance. + if err := h.checkHistoryContainer(ctx, ns, pod.Name, identity); err != nil { + return podHistoryPage{}, err + } + + page := podHistoryPage{Source: state.Name} + if index > 0 { + page.Before = historyIdentity(pod, steps[index-1]).encode() + } + if boundaryErr != nil { + if capped { + page.Limited = true + return page, nil + } + return podHistoryPage{}, boundaryErr + } + if capped && cursor.Anchor == "" { + // The kubelet byte limit cuts the end, so the partial response cannot be + // presented as the newest complete page. Earlier containers still work. + page.Limited = true + return page, nil + } + lines, start, clipped := podPageRows(rows, end, podHistoryLines) + page.Limited = clipped || limitedWindow + if len(lines) == 0 { + if usedSince { + page.Limited = true + } + return page, nil + } + page.Text = strings.Join(lines, "\n") + "\n" + if start > 0 || (!usedSince && int64(len(rows)) == tail && tail < podHistoryMaxTail) { + identity.Anchor = podRecordID(rows[start]) + identity.Depth = min(int64(podHistoryMaxTail), max(cursor.Depth, int64(len(rows)-start))) + stamp, _, _ := strings.Cut(rows[0], " ") + if t, err := time.Parse(time.RFC3339Nano, stamp); err == nil { + identity.Since = t.Add(-time.Second).Unix() + } + page.Before = identity.encode() + } else if usedSince || int64(len(rows)) == tail { + page.Limited = true + } + return page, nil +} + +func (h *podLogProxy) checkHistoryContainer(ctx context.Context, ns, name string, identity podHistoryCursor) error { + pod, err := h.readPod(ctx, ns, name) + if apierrors.IsNotFound(err) { + return errPodHistoryChanged + } + if err != nil { + return err + } + for _, state := range historyContainers(pod) { + if sameHistoryContainer(identity, pod, state) { + return nil + } + } + return errPodHistoryChanged +} + +// Revalidate the container immediately before every log subrequest. Keep the +// local reader limit even when a proxy or a fake kubelet ignores LimitBytes. +func (h *podLogProxy) historyWindow(ctx context.Context, ns, name string, identity podHistoryCursor, opts *corev1.PodLogOptions) ([]string, bool, error) { + if err := h.checkHistoryContainer(ctx, ns, name, identity); err != nil { + return nil, false, err + } + nbytes := int64(podHistoryReadBytes + 1) + opts.LimitBytes = &nbytes + stream, err := h.k.Typed.CoreV1().Pods(ns).GetLogs(name, opts).Stream(ctx) + if err != nil { + return nil, false, fmt.Errorf("open container log: %w", err) + } + defer func() { _ = stream.Close() }() + data, err := io.ReadAll(io.LimitReader(stream, nbytes)) + if err != nil { + return nil, false, fmt.Errorf("read container log: %w", err) + } + capped := len(data) > podHistoryReadBytes + if capped { + cut := bytes.LastIndexByte(data[:podHistoryReadBytes], '\n') + if cut < 0 { + return nil, true, nil + } + data = data[:cut+1] + } + if len(data) == 0 { + return nil, capped, nil + } + return strings.Split(string(bytes.TrimSuffix(data, []byte{'\n'})), "\n"), capped, nil +} diff --git a/api/internal/ws/pod_history_test.go b/api/internal/ws/pod_history_test.go new file mode 100644 index 000000000..9fcf1036f --- /dev/null +++ b/api/internal/ws/pod_history_test.go @@ -0,0 +1,160 @@ +package ws + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +func TestPodHistoryAnchorAcrossAppend(t *testing.T) { + rows := []string{"2026-01-01T12:00:00.000000001Z one", "2026-01-01T12:00:00.000000002Z two", "2026-01-01T12:00:00.000000003Z two"} + anchor := podRecordID(rows[2]) + rows = append(rows, "2026-01-01T12:00:01Z new") + end, err := podBoundary(rows, anchor) + older, start, _ := podPageRows(rows, end, 2) + if err != nil || start != 0 || strings.Join(older, ",") != "one,two" { + t.Fatalf("older=%v err=%v", older, err) + } + if _, err = podBoundary(rows[3:], anchor); !errors.Is(err, errPodHistoryChanged) { + t.Fatalf("lost anchor: %v", err) + } + if _, err = podBoundary(append(rows, rows[2]), anchor); !errors.Is(err, errPodHistoryChanged) { + t.Fatalf("ambiguous anchor: %v", err) + } +} + +func TestPodHistoryRoutesAndContinuity(t *testing.T) { + pod := newPod("alpha-0", []string{"setup"}, false) + pod.Status.ContainerStatuses[0].ContainerID = "containerd://game" + fakeClient := fake.NewSimpleClientset(pod) + registry := streamTestRegistry(&kube.Client{Typed: fakeClient}) + rows := []string{} + appendRows := func(n int) { + for i := 0; i < n; i++ { + stamp := time.Unix(1710000000+int64(len(rows)), 0).UTC() + rows = append(rows, fmt.Sprintf("%s row-%d", stamp.Format(time.RFC3339Nano), len(rows))) + } + } + appendRows(150) + var readSince, replaceDuringRead bool + fakeClient.PrependReactor("get", "pods/log", func(action ktesting.Action) (bool, runtime.Object, error) { + opts := action.(ktesting.GenericAction).GetValue().(*corev1.PodLogOptions) + if opts.LimitBytes == nil || *opts.LimitBytes > podHistoryReadBytes+1 || !opts.Timestamps { + t.Fatal("unbounded pod read") + } + if replaceDuringRead { + replacement := pod.DeepCopy() + replacement.UID = "replacement" + if err := fakeClient.Tracker().Update(corev1.SchemeGroupVersion.WithResource("pods"), replacement, pod.Namespace); err != nil { + t.Fatal(err) + } + } + selected := rows + if opts.Container == "setup" { + selected = []string{"2026-01-01T12:00:00Z setup-output"} + } + if opts.TailLines != nil { + selected = selected[max(0, len(selected)-int(*opts.TailLines)):] + } + if opts.SinceTime != nil { + readSince = true + for len(selected) > 0 { + ts, _, _ := strings.Cut(selected[0], " ") + stamp, _ := time.Parse(time.RFC3339Nano, ts) + if !stamp.Before(opts.SinceTime.Time) { + break + } + selected = selected[1:] + } + } + return true, &runtime.Unknown{Raw: []byte(strings.Join(selected, "\n") + "\n")}, nil + }) + router := chi.NewRouter() + mountPodLogs(router, registry) + read := func(before string, want int) podHistoryPage { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/servers/alpha/logs/pod/history?before="+url.QueryEscape(before), nil) + router.ServeHTTP(rec, req) + if rec.Code != want { + t.Fatalf("status=%d want=%d body=%s", rec.Code, want, rec.Body.String()) + } + var page podHistoryPage + if want == 200 { + if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + } + return page + } + read("%", 400) + read(strings.Repeat("x", 2049), 400) + first := read("", 200) + if first.Source != "game" || !strings.HasPrefix(first.Text, "row-50\n") || !strings.HasSuffix(first.Text, "row-149\n") { + t.Fatalf("first=%+v", first) + } + appendRows(600) + older := read(first.Before, 200) + if !readSince || !strings.HasPrefix(older.Text, "row-0\n") || !strings.HasSuffix(older.Text, "row-49\n") { + t.Fatalf("older=%+v", older) + } + setup := read(older.Before, 200) + if setup.Text != "setup-output\n" || setup.Source != "setup" || setup.Before != "" { + t.Fatalf("setup=%+v", setup) + } + // One MiB from the beginning of a 164-line tail misses the newest + // records. A smaller bounded tail can still return useful pages. + for i := range rows { + rows[i] += " " + strings.Repeat("a", 8000) + } + large := read("", 200) + if !strings.Contains(large.Text, "row-749 ") || len(large.Text) > podHistoryBytes || large.Before == "" { + t.Fatalf("missing latest records from the capped window: %d bytes", len(large.Text)) + } + previous := read(large.Before, 200) + if previous.Text == "" || strings.Contains(previous.Text, "row-749 ") { + t.Fatalf("missing preceding records from the capped window: %d bytes", len(previous.Text)) + } + pod.Status.ContainerStatuses[0].RestartCount++ + if _, err := fakeClient.CoreV1().Pods(pod.Namespace).UpdateStatus(t.Context(), pod, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + read(first.Before, 409) + rows = []string{"2026-01-01T12:00:00Z " + strings.Repeat("x", podHistoryReadBytes+1), "2026-01-01T12:00:01Z latest"} + afterGiant := read("", 200) + if afterGiant.Text != "latest\n" || !afterGiant.Limited { + t.Fatalf("newest output after a large record: %+v", afterGiant) + } + rows = rows[:1] + if page := read("", 200); !page.Limited || page.Text != "" { + t.Fatalf("oversized record: %+v", page) + } + replaceDuringRead = true + read("", 409) +} + +func TestPodHistoryCapsLargeRecords(t *testing.T) { + rows := []string{"2026-01-01T12:00:00Z " + strings.Repeat("a", podHistoryBytes), "2026-01-01T12:00:01Z next"} + lines, start, limited := podPageRows(rows, 2, 100) + if limited || len(lines) != 1 || start != 1 { + t.Fatal("page crossed its byte bound") + } + lines, start, limited = podPageRows(rows, 1, 100) + if !limited || len(lines[0]) > podHistoryBytes || start != 0 { + t.Fatal("long line was not clipped") + } +} diff --git a/api/internal/ws/podlogs.go b/api/internal/ws/podlogs.go index 557edaab6..d8c3ad753 100644 --- a/api/internal/ws/podlogs.go +++ b/api/internal/ws/podlogs.go @@ -46,6 +46,7 @@ const podLogPollInterval = time.Second // /logs/tail protocol so the dashboard's Logs tab renders both sources // identically. func mountPodLogs(r chi.Router, reg *kube.Registry) { + mountPodHistory(r, reg) r.Get("/ws/servers/{name}/logs/pod", func(w http.ResponseWriter, req *http.Request) { k, ok := streamClient(w, req, reg) if !ok { diff --git a/api/specs.md b/api/specs.md index 469452d18..517cc9a7a 100644 --- a/api/specs.md +++ b/api/specs.md @@ -658,6 +658,10 @@ Foreign keys are enforced only on Postgres (modernc-sqlite runs with FK OFF); th - **Central administration:** cluster node-join/kubeconfig, users, provider credentials, module catalog and installation management remain central administrative operations. `TestHomeClientMounts_ServeHomeClusterOnly` and `TestHomeClientMounts_MatchMain` retain their local-client boundary checks. Remote routing, identity and isolation tests cover the registry-backed mounts separately. - **Gateway-enabled agent routes:** the production mount enables selected-cluster RCON, game-file logs/downloads, files, players, status and agent-based mods through the optional gateway. RCON module actions use that gateway; stdin actions, Pod logs and PTY attach use the selected Kubernetes client. GET `/servers/{name}/mods/updates` uses `MountModUpdatesWithRegistry` so its template and agent reads target the same selected cluster. Consumers mounted without the remote resolver retain the legacy 501 guard; missing or invalid gateway configuration fails closed rather than using a local agent. Tests: `TestModUpdatesRoutesAgentAndTemplateToRemoteCluster`, `TestModUpdatesRemoteCannotUseLocalOnlyAgent` and `ws/gateway_client_test.go` cover remote routing and fail-closed behavior. +### Server log history + +`GET /servers/{name}/logs/history` reads the configured game file using the existing local or remote agent transport. `GET /servers/{name}/logs/pod/history` uses the selected Kubernetes client. Both require the same server read permission as live logs and return `text` plus an optional opaque `before` cursor. Pass the cursor to read an earlier page. Pod pages identify the source container and continue into completed init containers in reverse startup order. The API checks the pod and container identity around each read and returns 409 when the recorded boundary is stale or ambiguous. Pod reads are capped at 1 MiB plus one sentinel byte per attempt, with an initial tail, up to four shorter tails for dense output and one timestamp fallback; each output page contains at most 100 lines and 64 KiB of text. `limited` identifies clipped records or an exhausted lookback window. + ### GameServer config password redaction - Every covered response omits `metadata.annotations["kubectl.kubernetes.io/last-applied-configuration"]`: this historical manifest can contain passwords absent from the current config or template. Omission applies even when current config is absent, empty, or malformed; stored Kubernetes metadata and other annotations remain unchanged. `GameServer.spec.config` stores wizard values in clear, including those of `type: password` fields in the template's `configSchema`. Anyone holding `servers:read` (or the owner/collaborator fallback) could therefore read every password. The API now redacts them; implementation and tests are in `api/internal/handlers/config_redact.go` and `config_redact_test.go`. diff --git a/assets/design-export/json/Xn5ns.json b/assets/design-export/json/Xn5ns.json index c1aa22344..7a1577694 100644 --- a/assets/design-export/json/Xn5ns.json +++ b/assets/design-export/json/Xn5ns.json @@ -101,6 +101,99 @@ "fill": "$background/background", "layout": "vertical", "children": [ + { + "type": "frame", + "id": "gD1oH", + "name": "Recent pod output", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "gap": 6, + "padding": [ + 6, + 12 + ], + "children": [ + { + "type": "frame", + "id": "tafSr", + "name": "History controls", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Pf4hf", + "fill": "$foreground/foreground", + "content": "Recent pod output", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wjGhk", + "name": "Hide history", + "fill": "$foreground/foreground", + "content": "Hide history", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ldOKG", + "name": "Load older", + "fill": "$foreground/foreground", + "content": "Load older", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Q71fl", + "fill": "$foreground/foreground", + "content": "game", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "sVEV6", + "fill": "$muted", + "content": "Pod logs and the live console use independent streams. Output may overlap or have gaps between them.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "a9BGDp", + "name": "Pod log snapshot", + "width": "fill_container", + "fill": "$surface/tertiary", + "padding": 8, + "children": [ + { + "type": "text", + "id": "Y5Oxw", + "fill": "$foreground/foreground", + "content": "[Server thread/INFO] Preparing level world", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, { "id": "cx9wt", "type": "ref", diff --git a/assets/design-export/json/kPmoo.json b/assets/design-export/json/kPmoo.json index cee7cb21f..5b65389b3 100644 --- a/assets/design-export/json/kPmoo.json +++ b/assets/design-export/json/kPmoo.json @@ -320,6 +320,31 @@ } ] }, + { + "type": "frame", + "id": "M6zSv", + "name": "Earlier output", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "padding": [ + 8, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZuO5r", + "fill": "$foreground/foreground", + "content": "Earlier output", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, { "type": "frame", "id": "Ra2fe", diff --git a/assets/design-export/screenshots/Xn5ns.png b/assets/design-export/screenshots/Xn5ns.png index 6c5fbf508..45a7552ab 100644 Binary files a/assets/design-export/screenshots/Xn5ns.png and b/assets/design-export/screenshots/Xn5ns.png differ diff --git a/assets/design-export/screenshots/kPmoo.png b/assets/design-export/screenshots/kPmoo.png index 786682289..5a166243f 100644 Binary files a/assets/design-export/screenshots/kPmoo.png and b/assets/design-export/screenshots/kPmoo.png differ diff --git a/changelog/unreleased/936-log-history.md b/changelog/unreleased/936-log-history.md new file mode 100644 index 000000000..be7cf88c7 --- /dev/null +++ b/changelog/unreleased/936-log-history.md @@ -0,0 +1,3 @@ +# Earlier game-file and container output + +The Logs tab can page through earlier game-file output and game or setup container logs while the live stream stays connected. The Console tab shows a separate pod-output snapshot alongside the live PTY or RCON session. [#936](https://github.com/GameplanePanel/Gameplane/issues/936) diff --git a/design.pen b/design.pen index 876a2bbb1..3d4b65add 100644 --- a/design.pen +++ b/design.pen @@ -1,5 +1,5 @@ { - "version": "2.21", + "version": "2.22", "children": [ { "type": "frame", @@ -3423,6 +3423,99 @@ "fill": "$background/background", "layout": "vertical", "children": [ + { + "type": "frame", + "id": "gD1oH", + "name": "Recent pod output", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "layout": "vertical", + "gap": 6, + "padding": [ + 6, + 12 + ], + "children": [ + { + "type": "frame", + "id": "tafSr", + "name": "History controls", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Pf4hf", + "fill": "$foreground/foreground", + "content": "Recent pod output", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wjGhk", + "name": "Hide history", + "fill": "$foreground/foreground", + "content": "Hide history", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ldOKG", + "name": "Load older", + "fill": "$foreground/foreground", + "content": "Load older", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Q71fl", + "fill": "$foreground/foreground", + "content": "game", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "sVEV6", + "fill": "$muted", + "content": "Pod logs and the live console use independent streams. Output may overlap or have gaps between them.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "a9BGDp", + "name": "Pod log snapshot", + "width": "fill_container", + "fill": "$surface/tertiary", + "padding": 8, + "children": [ + { + "type": "text", + "id": "Y5Oxw", + "fill": "$foreground/foreground", + "content": "[Server thread/INFO] Preparing level world", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, { "id": "cx9wt", "type": "ref", @@ -3842,6 +3935,31 @@ } ] }, + { + "type": "frame", + "id": "M6zSv", + "name": "Earlier output", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "padding": [ + 8, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZuO5r", + "fill": "$foreground/foreground", + "content": "Earlier output", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, { "type": "frame", "id": "Ra2fe", diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index af51c12f9..f7a14e00e 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -4547,6 +4547,46 @@ "defaultMessage": "Must be a whole number.", "description": "Config field validation: integer field value is not a whole number" }, + "web.logs.history.boundary": { + "defaultMessage": "Pod logs and the live console use independent streams. Output may overlap or have gaps between them.", + "description": "Explain the boundary between the console and the pod output snapshot" + }, + "web.logs.history.changed": { + "defaultMessage": "This log changed or its earlier output expired. Reopen history to read the current log.", + "description": "A server log cursor no longer matches its source" + }, + "web.logs.history.console": { + "defaultMessage": "Recent pod output", + "description": "Pod log snapshot shown alongside the live terminal" + }, + "web.logs.history.empty": { + "defaultMessage": "No earlier output.", + "description": "The log snapshot is empty" + }, + "web.logs.history.failed": { + "defaultMessage": "Could not load log history.", + "description": "The selected server's log history request failed" + }, + "web.logs.history.hide": { + "defaultMessage": "Hide history", + "description": "Close the historical log page" + }, + "web.logs.history.limited": { + "defaultMessage": "Some output exceeds the history window.", + "description": "A container record or history read exceeded its size limit" + }, + "web.logs.history.loading": { + "defaultMessage": "Loading history…", + "description": "A log history page is loading" + }, + "web.logs.history.older": { + "defaultMessage": "Load older", + "description": "Read the preceding page of this log" + }, + "web.logs.history.open": { + "defaultMessage": "Earlier output", + "description": "Open a snapshot of older log output" + }, "web.nodes.all_clusters": { "defaultMessage": "All clusters", "description": "Nodes: option to include every authorized cluster" diff --git a/web/e2e/screenshots/slice2a.spec.ts b/web/e2e/screenshots/slice2a.spec.ts index 9f85525af..5888a1216 100644 --- a/web/e2e/screenshots/slice2a.spec.ts +++ b/web/e2e/screenshots/slice2a.spec.ts @@ -147,16 +147,28 @@ test.describe("Slice 2a: Servers + core tabs (Desktop — 1440x900) @screenshots await page.goto("/servers/mc-survival"); await clickTab(page, "Console"); await expect(page.getByText("— connected —").first()).toBeVisible({ timeout: 15_000 }); - await page.waitForTimeout(200); + await expect(page.getByText("[Server thread/INFO] Preparing level world", { exact: true })).toBeVisible(); + const older = page.getByRole("button", { name: "Load older" }); + await expect(older).toBeEnabled(); await capture(page, "Xn5ns"); + await older.click(); + await expect(page.getByText("[Server thread/INFO] Loading properties", { exact: true })).toBeVisible(); + await expect(older).toHaveCount(0); + await expect(page.getByText("— connected —").first()).toBeVisible(); }); test("kPmoo: Server Detail — Logs", async ({ page }) => { await page.goto("/servers/mc-survival"); await clickTab(page, "Logs"); - await expect(page.getByText(/Starting minecraft server version/i).first()).toBeVisible({ timeout: 15_000 }); - await page.waitForTimeout(200); + const live = page.getByText(/Starting minecraft server version/i).first(); + await expect(live).toBeVisible({ timeout: 15_000 }); + const earlier = page.getByRole("button", { name: "Earlier output" }); + await expect(earlier).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByText(/Server empty for 60 seconds/i)).toBeVisible(); await capture(page, "kPmoo"); + await earlier.click(); + await expect(page.getByText("[Server thread/INFO] Preparing level world", { exact: true })).toBeVisible(); + await expect(live).toBeVisible(); }); test("FtdkI: Server Detail — Logs (Failed)", async ({ context, page }) => { diff --git a/web/specs.md b/web/specs.md index 6f8520031..69e229676 100644 --- a/web/specs.md +++ b/web/specs.md @@ -1138,6 +1138,7 @@ openWS(path: string, opts: WSOptions) **Used by:** - Console tab: streams RCON/stdin input/output (bidirectional) - Logs tab: streams container stdout and/or game log file (read-only) +- **Earlier output** opens a bounded historical page independently of the live connection. **Load older** follows the source cursor, including across completed init containers. Console shows recent pod output separately from the interactive PTY or RCON session. Live attachment begins immediately. The two transports can overlap or have a gap. **Cluster routing:** WebSocket paths retain the open server's cluster and namespace. Pod startup/stdout logs and PTY attach use that cluster's Kubernetes client; remote RCON and game-file logs use its authenticated gateway. Reconnects retain the original target. Leaving the server view closes its streams; infrastructure selection cannot retarget them. diff --git a/web/src/lib/endpoints.ts b/web/src/lib/endpoints.ts index 991a27686..b8fb2c69b 100644 --- a/web/src/lib/endpoints.ts +++ b/web/src/lib/endpoints.ts @@ -62,6 +62,13 @@ function withNS(path: string, ns?: string): string { // Helper to append cluster query param for multi-cluster support. // Only appends when the current cluster is non-local (preserves back-compat). +export interface LogHistoryPage { + text: string; + before?: string; + source?: string; + limited?: boolean; +} + export function withCluster(path: string, clusterId = "local"): string { return withClusterParam(path, clusterId); } @@ -1012,6 +1019,10 @@ function makeResourceEndpoints(request: RequestClient) { }; const Logs = { + history: (server: string, source: "pod" | "file", before = "", ns?: string) => { + const route = source === "pod" ? "logs/pod/history" : "logs/history"; + return api(withNS(`/servers/${encodeURIComponent(server)}/${route}?before=${encodeURIComponent(before)}`, ns)); + }, downloadURL: (server: string, ns?: string) => withCluster(withNS(`/servers/${encodeURIComponent(server)}/logs/download`, ns)), // Live tail of the configured game log file, via the agent (mTLS). diff --git a/web/src/routes/tabs/Console.test.tsx b/web/src/routes/tabs/Console.test.tsx index 0ac6eaf2a..b1e97bd37 100644 --- a/web/src/routes/tabs/Console.test.tsx +++ b/web/src/routes/tabs/Console.test.tsx @@ -155,6 +155,20 @@ async function renderConsole(mode: "rcon" | "pty") { } describe("ConsoleTab", () => { + it("attaches the live console while a pod history request is pending", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + server.use(http.get("/servers/alpha/logs/pod/history", async () => { + await pending; + return HttpResponse.json({ text: "pod snapshot" }); + })); + const { term } = await renderConsole("pty"); + expect(mocks.wsCalls.length).toBeGreaterThan(0); + release(); + expect(await screen.findByText("pod snapshot")).toBeInTheDocument(); + expect(term.write).not.toHaveBeenCalledWith(expect.stringContaining("pod snapshot")); + expect(screen.getByText(/output may overlap or have gaps/i)).toBeInTheDocument(); + }); it("keeps filters independent and disposes the old terminal when its resource route changes", async () => { const { term, unmount, rerender, client } = await renderConsole("pty"); const calls = mocks.wsCalls.length; diff --git a/web/src/routes/tabs/Console.tsx b/web/src/routes/tabs/Console.tsx index 7b3f75ebe..c333d0f51 100644 --- a/web/src/routes/tabs/Console.tsx +++ b/web/src/routes/tabs/Console.tsx @@ -8,6 +8,7 @@ import { createRequestClient } from "@/lib/api"; import { resolveConsoleMode } from "@/lib/capabilities"; import { ConsoleShell } from "./ConsoleShell"; +import { LogHistory } from "./LogHistory"; import { useConsoleTerminal } from "./useConsoleTerminal"; import { useOptionalIntl } from "@/lib/i18n/format"; @@ -44,7 +45,12 @@ export function ConsoleTab({ name, ns }: { name: string; ns?: string }) { ); } - return mode === "pty" ? : ; + return
+ +
+ {mode === "pty" ? : } +
+
; } // RconConsole speaks the existing line-based RCON protocol — text is diff --git a/web/src/routes/tabs/LogHistory.tsx b/web/src/routes/tabs/LogHistory.tsx new file mode 100644 index 000000000..f2654124c --- /dev/null +++ b/web/src/routes/tabs/LogHistory.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@heroui/react"; +import { defineMessages } from "react-intl"; + +import { APIError } from "@/lib/api"; +import type { LogHistoryPage } from "@/lib/endpoints"; +import { useOptionalIntl } from "@/lib/i18n/format"; +import { useResourceClient, useResourceTarget } from "@/lib/resourceTarget"; + +const labels = defineMessages({ + open: { id: "web.logs.history.open", defaultMessage: "Earlier output", description: "Open a snapshot of older log output" }, + older: { id: "web.logs.history.older", defaultMessage: "Load older", description: "Read the preceding page of this log" }, + hide: { id: "web.logs.history.hide", defaultMessage: "Hide history", description: "Close the historical log page" }, + loading: { id: "web.logs.history.loading", defaultMessage: "Loading history…", description: "A log history page is loading" }, + empty: { id: "web.logs.history.empty", defaultMessage: "No earlier output.", description: "The log snapshot is empty" }, + limited: { id: "web.logs.history.limited", defaultMessage: "Some output exceeds the history window.", description: "A container record or history read exceeded its size limit" }, + changed: { id: "web.logs.history.changed", defaultMessage: "This log changed or its earlier output expired. Reopen history to read the current log.", description: "A server log cursor no longer matches its source" }, + failed: { id: "web.logs.history.failed", defaultMessage: "Could not load log history.", description: "The selected server's log history request failed" }, + console: { id: "web.logs.history.console", defaultMessage: "Recent pod output", description: "Pod log snapshot shown alongside the live terminal" }, + boundary: { id: "web.logs.history.boundary", defaultMessage: "Pod logs and the live console use independent streams. Output may overlap or have gaps between them.", description: "Explain the boundary between the console and the pod output snapshot" }, +}); + +type Props = { name: string; ns?: string; source: "pod" | "file"; console?: boolean }; + +export function LogHistory(props: Props) { + const target = useResourceTarget({ name: props.name, namespace: props.ns }); + return ; +} + +function History({ name, ns, source, console: consoleView = false }: Props) { + const intl = useOptionalIntl(); + const target = useResourceTarget({ name, namespace: ns }); + const client = useResourceClient(target); + const [open, setOpen] = useState(consoleView); + const [before, setBefore] = useState(""); + const [page, setPage] = useState(); + const [error, setError] = useState<"changed" | "failed">(); + const [loading, setLoading] = useState(consoleView); + const output = useRef(null); + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + void client.withSignal(controller.signal).Logs.history(name, source, before, ns).then((result) => { + if (controller.signal.aborted) return; + setPage(result); + if (output.current) output.current.scrollTop = 0; + }).catch((cause: unknown) => { + if (!controller.signal.aborted) setError(cause instanceof APIError && cause.status === 409 ? "changed" : "failed"); + }).finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [open, before, name, ns, source, client]); + + const toggle = () => { + setPage(undefined); + setError(undefined); + setBefore(""); + setLoading(!open); + setOpen(!open); + }; + return
+
+ {consoleView && {intl.formatMessage(labels.console)}} + + {open && page?.before && !error && } + {open && page?.source && {page.source}} +
+ {open &&
+ {consoleView &&

{intl.formatMessage(labels.boundary)}

} +
{loading ? intl.formatMessage(labels.loading) : error ? intl.formatMessage(labels[error]) : page?.limited ? intl.formatMessage(labels.limited) : null}
+ {page?.text ?
{page.text}
: + !loading && !error && !page?.limited &&

{intl.formatMessage(labels.empty)}

} +
} +
; +} diff --git a/web/src/routes/tabs/Logs.test.tsx b/web/src/routes/tabs/Logs.test.tsx index 0c65e73d6..c77d3ff70 100644 --- a/web/src/routes/tabs/Logs.test.tsx +++ b/web/src/routes/tabs/Logs.test.tsx @@ -45,6 +45,8 @@ vi.mock("@/lib/ws", () => ({ import { setCurrentCluster } from "@/lib/cluster"; import { LogsTab, parseLogLevel } from "./Logs"; +import { http, HttpResponse } from "msw"; +import { server } from "@/test/server"; beforeEach(() => { setCurrentCluster("local"); @@ -56,6 +58,53 @@ afterEach(() => { }); describe("LogsTab", () => { + it("opens earlier pages on a short viewport and keeps the live socket attached", async () => { + const requests: string[] = []; + server.use(http.get("/servers/alpha/logs/pod/history", ({ request }) => { + const cursor = new URL(request.url).searchParams.get("before") ?? ""; + requests.push(cursor); + return HttpResponse.json(cursor ? { text: "oldest row", source: "setup" } : { text: "recent row", source: "game", before: "stable-boundary" }); + })); + render(); + await userEvent.click(screen.getByRole("button", { name: "Earlier output" })); + expect(await screen.findByText("recent row")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load older" })); + expect(await screen.findByText("oldest row")).toBeInTheDocument(); + expect(requests).toEqual(["", "stable-boundary"]); + expect(sockets[0].close).not.toHaveBeenCalled(); + }); + it("resets file history on a source change and reports a rotated file", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const queries: URL[] = []; + server.use( + http.get("/servers/alpha/logs/pod/history", async () => { + await pending; + return HttpResponse.json({ text: "stale pod response" }); + }), + http.get("/servers/alpha/logs/history", ({ request }) => { + const url = new URL(request.url); + queries.push(url); + return url.searchParams.get("before") ? new HttpResponse(null, { status: 409 }) : + HttpResponse.json({ text: "file row", before: "file-cursor" }); + }), + ); + render( + + ); + await userEvent.click(screen.getByRole("button", { name: "Earlier output" })); + await userEvent.click(screen.getByRole("tab", { name: /game log/i })); + release(); + await userEvent.click(screen.getByRole("button", { name: "Earlier output" })); + expect(await screen.findByText("file row")).toBeInTheDocument(); + expect(screen.queryByText("stale pod response")).not.toBeInTheDocument(); + expect(queries[0].searchParams.get("cluster")).toBe("remote"); + expect(queries[0].searchParams.get("namespace")).toBe("games"); + await userEvent.click(screen.getByRole("button", { name: "Load older" })); + expect(await screen.findByText(/earlier output expired/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Hide history" })); + expect(screen.queryByText("file row")).not.toBeInTheDocument(); + }); it("keeps filters independent and clears output when its resource route changes", async () => { const { unmount, rerender } = render(); const old = sockets[0]; diff --git a/web/src/routes/tabs/Logs.tsx b/web/src/routes/tabs/Logs.tsx index 9b718032f..6abe028d8 100644 --- a/web/src/routes/tabs/Logs.tsx +++ b/web/src/routes/tabs/Logs.tsx @@ -8,6 +8,7 @@ import { Button, Input, Tabs, Tab as TabComponent } from "@heroui/react"; import { useFormat } from "@/lib/i18n/format"; import { openWS } from "@/lib/ws"; +import { LogHistory } from "./LogHistory"; import { capitalize, cn } from "@/lib/utils"; import type { GameServerPhase } from "@/types"; import { useOptionalIntl } from "@/lib/i18n/format"; @@ -208,6 +209,7 @@ export function LogsTab({ {intl.formatMessage({ id: "web.routes.tabs.logs.download", defaultMessage: "Download", description: "Logs tab: button that downloads the log as a file; keep it short" })} +
{noOutput ? (
diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index 1186a0f23..3dd64972f 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -47,6 +47,8 @@ export const INVALID_BPF_FILTER_FIXTURE = "tcp prot 8080 foo"; const isColonAction = (params: PathParams) => String(params.name).includes(":"); export const handlers = [ + http.get("/servers/:name/logs/pod/history", () => HttpResponse.json({ text: "", source: "game" })), + http.get("/servers/:name/logs/history", () => HttpResponse.json({ text: "" })), ...fleetHandlers, http.get("/admin/installation", () => HttpResponse.json({ standalone: false, localCluster: true })), // Auth @@ -1850,6 +1852,19 @@ export function buildScreenshotHandlers() { }); }), + http.get("/servers/:name/logs/pod/history", ({ request }) => { + const before = new URL(request.url).searchParams.get("before"); + if (!before) { + return HttpResponse.json({ + source: "game", text: "[Server thread/INFO] Preparing level world", before: "previous-page", + }); + } + if (before === "previous-page") { + return HttpResponse.json({ source: "game", text: "[Server thread/INFO] Loading properties" }); + } + return new HttpResponse(null, { status: 409 }); + }), + // WebSocket: Pod Logs ws.link(`${wsOrigin}/ws/servers/*/logs/pod*`).addEventListener("connection", ({ client }) => { if (typeof document !== "undefined" && document.cookie.includes("e2e_server_variant=failed")) return;