From bf550b5f4281d56c7baba35af56854424e03a437 Mon Sep 17 00:00:00 2001 From: Baylem Date: Thu, 12 Nov 2026 00:16:19 -0500 Subject: [PATCH 1/5] feat(files): add scoped editor tabs and archive selections Signed-off-by: Baylem --- agent/internal/files/archive_linux.go | 235 ++++++++++++++ agent/internal/files/archive_test.go | 109 +++++++ agent/internal/files/files.go | 6 +- agent/internal/files/timeouts_test.go | 2 +- agent/openapi.yaml | 29 ++ agent/specs.md | 9 +- api/cmd/main.go | 2 + api/docs/openapi.yaml | 29 ++ api/internal/gatewayprotocol/duration.go | 3 + api/internal/gatewayprotocol/protocol.go | 4 +- api/internal/rbac/middleware_test.go | 15 + api/internal/ws/archive_route_test.go | 48 +++ api/internal/ws/dialer.go | 8 +- api/specs.md | 1 + changelog/unreleased/935-file-workspace.md | 6 + i18n/locales/en/app.json | 48 +++ web/e2e/specs/fileWorkspace.spec.ts | 21 ++ web/specs.md | 4 +- web/src/lib/endpoints.ts | 16 +- web/src/lib/fileDrafts.test.ts | 36 +++ web/src/lib/fileDrafts.ts | 126 ++++++++ web/src/router/tree.tsx | 6 +- web/src/routes/ServerDetail.tsx | 33 +- web/src/routes/tabs/FileTreeRows.tsx | 63 ++++ web/src/routes/tabs/Files.test.tsx | 113 ++++--- web/src/routes/tabs/Files.tsx | 342 ++++++++++----------- 26 files changed, 1060 insertions(+), 254 deletions(-) create mode 100644 agent/internal/files/archive_linux.go create mode 100644 agent/internal/files/archive_test.go create mode 100644 api/internal/ws/archive_route_test.go create mode 100644 changelog/unreleased/935-file-workspace.md create mode 100644 web/e2e/specs/fileWorkspace.spec.ts create mode 100644 web/src/lib/fileDrafts.test.ts create mode 100644 web/src/lib/fileDrafts.ts create mode 100644 web/src/routes/tabs/FileTreeRows.tsx diff --git a/agent/internal/files/archive_linux.go b/agent/internal/files/archive_linux.go new file mode 100644 index 000000000..b4cb5295d --- /dev/null +++ b/agent/internal/files/archive_linux.go @@ -0,0 +1,235 @@ +package files + +import ( + "archive/zip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path" + "slices" + "strings" + "time" +) + +// ZIP downloads are staged before headers are sent, so source errors never +// become successful downloads of incomplete archives. The game volume is the +// agent's writable mount. Only one archive may use it at a time. +const ( + maxArchiveSelection = 256 + maxArchiveEntries = 10000 + maxArchiveBytes int64 = 1 << 30 +) + +var errArchiveLimit = errors.New("selection exceeds archive limits") + +func (h *handler) archive(w http.ResponseWriter, req *http.Request) { + if !h.archiveLock.TryLock() { + http.Error(w, "another archive is in progress", http.StatusConflict) + return + } + defer h.archiveLock.Unlock() + selected, prefix, err := h.archivePaths(req.URL.Query()["path"]) + if err != nil { + h.fail(w, err) + return + } + write := func(dst io.Writer) error { + files := selectionArchive{h: h, zip: zip.NewWriter(dst), ctx: req.Context(), names: make(map[string]bool)} + for _, components := range selected { + if err := files.add(components, strings.Join(components[prefix:], "/")); err != nil { + return err + } + } + return files.zip.Close() + } + if req.Method == http.MethodPost { + dest, parseErr := h.components(req.URL.Query().Get("dest")) + if parseErr != nil { + h.fail(w, parseErr) + return + } + if len(dest) == 0 || !strings.EqualFold(path.Ext(dest[len(dest)-1]), ".zip") { + http.Error(w, "choose a .zip filename", http.StatusBadRequest) + return + } + for _, source := range selected { + if len(dest) >= len(source) && slices.Equal(source, dest[:len(source)]) { + http.Error(w, "archive destination overlaps selection", http.StatusBadRequest) + return + } + } + err = h.createFile(dest, write) + if err != nil { + h.archiveError(w, err) + return + } + w.WriteHeader(http.StatusCreated) + return + } + tmp, err := os.CreateTemp(h.root, ".archive-") + if err != nil { + h.fail(w, err) + return + } + defer func() { _ = tmp.Close() }() + // Unlink now; even an interrupted request or a restarted agent leaves no file. + if err = os.Remove(tmp.Name()); err != nil { + h.fail(w, err) + return + } + if err = write(tmp); err != nil { + h.archiveError(w, err) + return + } + size, err := tmp.Seek(0, io.SeekCurrent) + if err != nil { + h.fail(w, err) + return + } + w.Header().Set("Content-Disposition", "attachment; filename=\"selection.zip\"") + w.Header().Set("Content-Type", "application/zip") + http.ServeContent(w, req, "selection.zip", time.Time{}, io.NewSectionReader(tmp, 0, size)) +} + +func (h *handler) archiveError(w http.ResponseWriter, err error) { + if errors.Is(err, errArchiveLimit) { + http.Error(w, errArchiveLimit.Error(), http.StatusRequestEntityTooLarge) + return + } + h.fail(w, err) +} + +func (h *handler) archivePaths(paths []string) ([][]string, int, error) { + if len(paths) == 0 || len(paths) > maxArchiveSelection { + return nil, 0, errBadPath + } + selected := make([][]string, 0, len(paths)) + names := make(map[string]bool, len(paths)) + for _, raw := range paths { + comps, err := h.components(raw) + if err != nil { + return nil, 0, err + } + if len(comps) == 0 || strings.ContainsAny(raw, "\\:") { + return nil, 0, errBadPath + } + key := strings.Join(comps, "/") + if names[key] { + return nil, 0, errBadPath + } + names[key] = true + selected = append(selected, comps) + } + prefix := parentsOf(selected[0]) + for _, comps := range selected { + for i := 1; i < len(comps); i++ { + if names[strings.Join(comps[:i], "/")] { + return nil, 0, errBadPath + } + } + for len(prefix) > len(comps)-1 || !slices.Equal(prefix, comps[:len(prefix)]) { + prefix = prefix[:len(prefix)-1] + } + } + return selected, len(prefix), nil +} + +type selectionArchive struct { + h *handler + zip *zip.Writer + ctx context.Context + names map[string]bool + size int64 +} + +func (a *selectionArchive) add(components []string, name string) error { + if err := a.ctx.Err(); err != nil { + return err + } + // Directory entries can move during traversal. Never publish two members + // with the same archive name, even if the filesystem reports one twice. + if a.names[name] { + return errBadPath + } + if len(a.names) >= maxArchiveEntries || len(components) > 128 { + return errArchiveLimit + } + a.names[name] = true + entry, err := a.h.openFile(components) + if err != nil { + return err + } + defer func() { _ = entry.Close() }() + info, err := entry.Stat() + if err != nil { + return fmt.Errorf("stat archive entry: %w", err) + } + if info.IsDir() { + return a.addFolder(components, name, entry, info) + } + if !info.Mode().IsRegular() { + return errBadPath + } + if info.Size() > maxArchiveBytes-a.size { + return errArchiveLimit + } + a.size += info.Size() + header := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: info.ModTime()} + dst, err := a.zip.CreateHeader(header) + if err != nil { + return fmt.Errorf("create archive entry: %w", err) + } + // Limit reads to the observed size, including for files that grow while being archived. + n, err := io.Copy(dst, io.LimitReader(archiveReader{a.ctx, entry}, info.Size())) + if err != nil { + return fmt.Errorf("copy archive entry: %w", err) + } + if n != info.Size() { + return io.ErrUnexpectedEOF + } + return nil +} + +func (a *selectionArchive) addFolder(components []string, name string, folder *os.File, info fs.FileInfo) error { + header := &zip.FileHeader{Name: name + "/", Modified: info.ModTime()} + header.SetMode(fs.ModeDir | 0o755) + if _, err := a.zip.CreateHeader(header); err != nil { + return fmt.Errorf("create archive directory: %w", err) + } + for { + names, err := folder.Readdirnames(128) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("list archive directory: %w", err) + } + for _, child := range names { + if strings.HasPrefix(child, ".") { + return errDotfile + } + if strings.ContainsAny(child, "\\:") { + return errBadPath + } + if err := a.add(append(slices.Clone(components), child), name+"/"+child); err != nil { + return err + } + } + if errors.Is(err, io.EOF) { + return nil + } + } +} + +type archiveReader struct { + ctx context.Context + src io.Reader +} + +func (r archiveReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.src.Read(p) +} diff --git a/agent/internal/files/archive_test.go b/agent/internal/files/archive_test.go new file mode 100644 index 000000000..a4ce38b94 --- /dev/null +++ b/agent/internal/files/archive_test.go @@ -0,0 +1,109 @@ +package files + +import ( + "archive/zip" + "bytes" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" +) + +func TestFileSelectionArchive(t *testing.T) { + address, root := newServer(t) + for _, folder := range []string{"first", "second"} { + if err := os.Mkdir(filepath.Join(root, folder), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, folder, "config"), []byte(folder), 0o600); err != nil { + t.Fatal(err) + } + } + paths := url.Values{"path": {"/first/config", "/second/config"}} + response := get(t, address, "/files/archive", paths) + payload, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("download: status %d, err %v", response.StatusCode, err) + } + archive, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + if len(archive.File) != 2 || archive.File[0].Name != "first/config" || archive.File[1].Name != "second/config" { + t.Fatalf("missing folder structure: %v", archive.File) + } + paths.Set("dest", "/selection.zip") + for _, want := range []int{http.StatusCreated, http.StatusConflict} { + response, err = testPost(t, address+"/files/archive?"+paths.Encode(), "application/octet-stream", nil) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != want { + t.Fatalf("save archive: got %d want %d", response.StatusCode, want) + } + } + for _, item := range []struct { + paths []string + status int + }{ + {[]string{"/no-such-file"}, 404}, {[]string{"/first", "/first/config"}, 400}, {[]string{"/first", "/first"}, 400}, + {[]string{"/.state"}, 400}, {[]string{"/"}, 400}, + } { + res := get(t, address, "/files/archive", url.Values{"path": item.paths}) + res.Body.Close() + if res.StatusCode != item.status { + t.Errorf("%v: status %d want %d", item.paths, res.StatusCode, item.status) + } + } +} + +func TestArchiveDoesNotPublishRefusedSelection(t *testing.T) { + address, root := newServer(t) + folder := filepath.Join(root, "folder") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + name string + want int + populate func(string) error + }{ + {"hidden", 400, func(p string) error { return os.WriteFile(filepath.Join(p, ".private"), []byte("secret"), 0o600) }}, + {"symlink", 400, func(p string) error { return os.Symlink(t.TempDir(), filepath.Join(p, "link")) }}, + {"large", 413, func(p string) error { + name := filepath.Join(p, "large") + if err := os.WriteFile(name, nil, 0o600); err != nil { + return err + } + return os.Truncate(name, maxArchiveBytes+1) + }}, + } { + t.Run(item.name, func(t *testing.T) { + child := filepath.Join(folder, item.name) + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatal(err) + } + if err := item.populate(child); err != nil { + t.Fatal(err) + } + q := url.Values{"path": {"/folder/" + item.name}, "dest": {"/result.zip"}} + getResult := get(t, address, "/files/archive", q) + getResult.Body.Close() + post, err := testPost(t, address+"/files/archive?"+q.Encode(), "application/octet-stream", nil) + if err != nil { + t.Fatal(err) + } + post.Body.Close() + if post.StatusCode != item.want || getResult.StatusCode != item.want { + t.Errorf("GET %d POST %d want %d", getResult.StatusCode, post.StatusCode, item.want) + } + if _, err := os.Stat(filepath.Join(root, "result.zip")); !os.IsNotExist(err) { + t.Fatalf("published refused archive: %v", err) + } + }) + } +} diff --git a/agent/internal/files/files.go b/agent/internal/files/files.go index ade67c6c6..e074577cf 100644 --- a/agent/internal/files/files.go +++ b/agent/internal/files/files.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -45,7 +46,8 @@ func hasDotComponent(rel string) bool { } type handler struct { - root string + archiveLock sync.Mutex + root string // beforeOpen is a test seam. When non-nil it is called synchronously // immediately before a path component is opened ("open"), before the @@ -64,6 +66,8 @@ func Mount(r chi.Router, root string) { r.Route("/files", func(r chi.Router) { // Transfers can legitimately outlive the ordinary operation timeout. r.Get("/download", h.download) + r.Get("/archive", h.archive) + r.Post("/archive", h.archive) r.Post("/upload", h.upload) r.Group(func(bounded chi.Router) { bounded.Use(middleware.Timeout(30 * time.Second)) diff --git a/agent/internal/files/timeouts_test.go b/agent/internal/files/timeouts_test.go index f17e9cd68..9e1db6fd0 100644 --- a/agent/internal/files/timeouts_test.go +++ b/agent/internal/files/timeouts_test.go @@ -18,7 +18,7 @@ func TestFileRouteTimeouts(t *testing.T) { t.Run(method+route, func(t *testing.T) { handler := chi.Chain(middlewares...).Handler(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { deadline, bounded := req.Context().Deadline() - transfer := route == "/files/download" || route == "/files/upload" + transfer := route == "/files/download" || route == "/files/upload" || route == "/files/archive" if bounded == transfer { t.Fatalf("bounded=%t, transfer=%t", bounded, transfer) } diff --git a/agent/openapi.yaml b/agent/openapi.yaml index 49b94061e..1be9bff52 100644 --- a/agent/openapi.yaml +++ b/agent/openapi.yaml @@ -164,6 +164,35 @@ paths: responses: "200": { description: File content } "413": { description: File too large — use /files/download } + '/files/archive': + parameters: + - in: query + name: path + required: true + style: form + explode: true + schema: { type: array, items: { type: string }, minItems: 1, maxItems: 256 } + get: + summary: Download selected paths as a ZIP + responses: + '200': { description: Completed ZIP, content: { application/zip: { schema: { type: string, format: binary } } } } + '400': { description: Invalid or protected selection } + '404': { description: A selected path does not exist } + '409': { description: Another archive is running } + '413': { description: Archive limit exceeded } + post: + summary: Create a ZIP on the server + parameters: + - in: query + name: dest + required: true + schema: { type: string } + responses: + '201': { description: Archive created } + '400': { description: Invalid destination or selection } + '404': { description: A selected path does not exist } + '409': { description: Destination exists or another archive is running } + '413': { description: Archive limit exceeded } /files/download: get: summary: Download a file as an attachment diff --git a/agent/specs.md b/agent/specs.md index 40935f565..3693ee66f 100644 --- a/agent/specs.md +++ b/agent/specs.md @@ -23,7 +23,14 @@ The agent is a per-pod HTTP/HTTPS sidecar that runs inside every game pod to exp - **Console & RCON**: Duplex WebSocket forwarding user commands to the game via RCON and echoing responses; supports Valve/Source, Telnet, WebSocket, BattlEye, Satisfactory, and Palworld protocols. - **File I/O**: List, read, download, upload, write, mkdir, delete within the `/data` volume; reject path traversal and any symlink traversal; every operation runs through directory descriptors rooted at the data directory, so it cannot be redirected by a concurrent filesystem change. - File uploads and downloads have no router-level total-duration timeout. The + GET `/files/archive` builds a downloadable ZIP of repeated `path` parameters. + POST with a `dest` creates a new ZIP atomically. Archive names are relative to + the common parent and retain directory structure. The agent refuses protected + entries, symlinks, special files, duplicate or overlapping selections and an + existing destination. One archive can run at a time per file handler, with + up to 256 selected paths, 10,000 entries and 1 GiB of input. + + Uploads, downloads and archives have no router-level total-duration timeout. The remaining file operations retain a 30-second timeout. Authentication, body size limits and path checks apply to transfers as well as short operations. - **Logs**: Tail the game container's log file over WebSocket (text frames per line); supports streaming from start or end. diff --git a/api/cmd/main.go b/api/cmd/main.go index b1bd15b53..649521124 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -897,6 +897,7 @@ func isLargeTransferPath(method, path string) bool { return false } return strings.HasSuffix(path, "/files/download") || + strings.HasSuffix(path, "/files/archive") || strings.HasSuffix(path, "/logs/download") || strings.HasSuffix(path, ":capture-file") } @@ -904,6 +905,7 @@ func isLargeTransferPath(method, path string) bool { // Uploads stream their body on req.Context(). URL installs also // wait for the agent's download and extraction, which may exceed 60s. return strings.HasSuffix(path, "/files/upload") || + strings.HasSuffix(path, "/files/archive") || strings.HasSuffix(path, "/files/write") || strings.HasSuffix(path, "/mods/upload") || strings.HasSuffix(path, "/mods/install") diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml index 89fb097f3..d6a903fbd 100644 --- a/api/docs/openapi.yaml +++ b/api/docs/openapi.yaml @@ -369,6 +369,35 @@ paths: responses: '200': description: OK + '/servers/{name}/files/archive': + parameters: + - in: query + name: path + required: true + style: form + explode: true + schema: { type: array, items: { type: string }, minItems: 1, maxItems: 256 } + get: + summary: Download selected paths as a ZIP + responses: + '200': { description: Completed ZIP, content: { application/zip: { schema: { type: string, format: binary } } } } + '400': { description: Invalid or protected selection } + '404': { description: A selected path does not exist } + '409': { description: Another archive is running } + '413': { description: Archive limit exceeded } + post: + summary: Create a ZIP on the server + parameters: + - in: query + name: dest + required: true + schema: { type: string } + responses: + '201': { description: Archive created } + '400': { description: Invalid destination or selection } + '404': { description: A selected path does not exist } + '409': { description: Destination exists or another archive is running } + '413': { description: Archive limit exceeded } '/servers/{name}/files/download': get: responses: diff --git a/api/internal/gatewayprotocol/duration.go b/api/internal/gatewayprotocol/duration.go index 09501b152..b6131a98a 100644 --- a/api/internal/gatewayprotocol/duration.go +++ b/api/internal/gatewayprotocol/duration.go @@ -6,6 +6,9 @@ import "net/http" // may arrive only after lengthy work. Caller cancellation and gateway lifetime // limits still apply; ordinary operations keep their short timeouts. func LongRunningOperation(method, path string) bool { + if path == "/files/archive" { + return method == http.MethodGet || method == http.MethodPost + } if method == http.MethodGet { return Streaming(path) || path == "/files/download" || path == "/logs/download" } diff --git a/api/internal/gatewayprotocol/protocol.go b/api/internal/gatewayprotocol/protocol.go index 584f2d41d..ab7cf9b47 100644 --- a/api/internal/gatewayprotocol/protocol.go +++ b/api/internal/gatewayprotocol/protocol.go @@ -53,13 +53,15 @@ 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", "/files/list", "/files/read", "/files/download", "/files/archive", "/players", "/players/banned", "/players/whitelist", "/status", "/mods": return 0, true } case http.MethodPost: switch path { case "/actions/run": return 16 << 10, true + case "/files/archive": + return 0, true case "/mods/upload": return 512 << 20, true case "/files/write", "/files/create", "/files/upload", "/files/mkdir", "/players/kick", "/players/ban", "/players/unban", "/players/whitelist/add", "/players/whitelist/remove", "/mods/install": diff --git a/api/internal/rbac/middleware_test.go b/api/internal/rbac/middleware_test.go index 68f086ef9..4ec305dd0 100644 --- a/api/internal/rbac/middleware_test.go +++ b/api/internal/rbac/middleware_test.go @@ -618,6 +618,21 @@ func TestMiddleware_StrangerDenied(t *testing.T) { } } +func TestArchiveRejectsUnrelatedUser(t *testing.T) { + h := Middleware(fakeServerFetcher{ + "gameplane-games/alpha": newOwnershipObj("alpha", 42, "alice", ""), + })(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("request reached proxy") })) + for _, method := range []string{http.MethodGet, http.MethodPost} { + req := httptest.NewRequestWithContext(t.Context(), method, "/servers/alpha/files/archive?path=%2Fconfig", nil) + req = req.WithContext(auth.WithUser(req.Context(), &auth.User{ID: 999})) + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + if res.Code != http.StatusForbidden { + t.Errorf("%s = %d", method, res.Code) + } + } +} + func TestMiddleware_UnknownClusterRejected(t *testing.T) { // A namespaced route with an unregistered ?cluster= selector must be // rejected with 400 before the permission check or fallback ever runs. diff --git a/api/internal/ws/archive_route_test.go b/api/internal/ws/archive_route_test.go new file mode 100644 index 000000000..41b299793 --- /dev/null +++ b/api/internal/ws/archive_route_test.go @@ -0,0 +1,48 @@ +package ws + +import ( + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" +) + +func TestArchiveProxyBindsTargetAndPreservesSelection(t *testing.T) { + calls := 0 + agent := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/v1/targets/gs-uid/files/archive" || !slices.Equal(r.URL.Query()["path"], []string{"/one/a", "/two/a"}) { + t.Errorf("archive request = %s", r.URL) + } + if r.Header.Get("X-Gameplane-Server-UID") != "" { + t.Error("browser precondition leaked upstream") + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("denied")) + })) + defer agent.Close() + k := &kube.Client{} + _ = streamTestRegistry(k) + p := &proxy{k: k, transport: testDirectTransport(agent)} + router := chi.NewRouter() + router.Get("/servers/{name}/files/archive", p.agentHTTP("/files/archive")) + for _, uid := range []string{"gs-uid", "previous"} { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/servers/alpha/files/archive?path=%2Fone%2Fa&path=%2Ftwo%2Fa", nil) + req.Header.Set("X-Gameplane-Server-UID", uid) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + if uid == "previous" { + if res.Code != http.StatusConflict { + t.Fatalf("replacement status %d", res.Code) + } + } else if res.Code != http.StatusForbidden || res.Body.String() != "denied" { + t.Fatalf("denial = %d %q", res.Code, res.Body.String()) + } + } + if calls != 1 { + t.Fatalf("upstream calls = %d", calls) + } +} diff --git a/api/internal/ws/dialer.go b/api/internal/ws/dialer.go index 662593f3a..bc93f81bc 100644 --- a/api/internal/ws/dialer.go +++ b/api/internal/ws/dialer.go @@ -65,6 +65,8 @@ func MountWithPlayerNames(r chi.Router, reg *kube.Registry, caBundle, clientCert r.Get("/list", p.agentHTTP("/files/list")) r.Get("/read", p.agentHTTP("/files/read")) r.Get("/download", p.agentHTTP("/files/download")) + r.Get("/archive", p.agentHTTP("/files/archive")) + r.Post("/archive", p.agentHTTP("/files/archive")) r.Post("/write", p.agentHTTP("/files/write")) r.Post("/create", p.agentHTTP("/files/create")) r.Post("/upload", p.agentHTTP("/files/upload")) @@ -240,6 +242,10 @@ func (p *proxy) httpProxyLimit(agentPath string, maxBody int64) http.HandlerFunc return } target := agentTarget{name: name, namespace: ns, uid: p.remoteUID} + if uid := req.Header.Get("X-Gameplane-Server-UID"); uid != "" && uid != target.uid { + http.Error(w, "server identity changed", http.StatusConflict) + return + } // Validate before opening an upstream or upgrading the browser connection. if err := target.validate(); err != nil { httperr.WriteCode(w, req, http.StatusBadRequest, err) @@ -261,7 +267,7 @@ func (p *proxy) httpProxyLimit(agentPath string, maxBody int64) http.HandlerFunc } defer func() { _ = resp.Body.Close() }() copyResponseHeaders(w.Header(), resp.Header) - if strings.HasSuffix(agentPath, "/download") { + if strings.HasSuffix(agentPath, "/download") || (agentPath == "/files/archive" && req.Method == http.MethodGet) { forceAttachment(w.Header()) } w.WriteHeader(resp.StatusCode) diff --git a/api/specs.md b/api/specs.md index 469452d18..f4dcf5da9 100644 --- a/api/specs.md +++ b/api/specs.md @@ -656,6 +656,7 @@ Foreign keys are enforced only on Postgres (modernc-sqlite runs with FK OFF); th - **Selected-cluster capture files:** GET `/servers/{name}:capture-file` and file cleanup during DELETE `:capture` use the private gateway for remote targets. Both server and capture UIDs are checked at the gateway and sidecar. Remote deletion accepts only a 204 bound cleanup acknowledgement or a 410 confirming both identity and PCAP absence before UID-guarded record deletion; ambiguous errors retain the record for retry. Cleanup may be skipped only for Completed captures with current-generation SidecarStopped=True, reason never_started, and no recorded gameplane.local/capture-pod-uid annotation, after validating server ownership. Pending/Running captures still require stop first; Failed phase or missing completion time is not absence proof. Existing local transport and cleanup behavior remain supported. Capture permissions and synchronous audit requirements do not change. Missing/old gateways fail closed, with no local fallback. - **Server capabilities:** GET `/servers/{name}/capabilities` requires the normal target-scoped server read permission and returns the exact target plus `capture.enabled`, `capture.files` and `capture.state`. Local enablement uses central configuration; remote enablement comes from the authenticated selected gateway. States are `ready`, `unsupported` or `unavailable`. Capabilities describe transport support and never grant capture permission. - **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. +- **File selection archives:** GET `/servers/{name}/files/archive` returns an attachment; POST creates a ZIP on the server. Both proxy repeated `path` parameters, preserve agent errors and honor the selected cluster and namespace. File editor writes and deletes may carry `X-Gameplane-Server-UID` to reject a stale client request with 409 when a server has been replaced. - **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. ### GameServer config password redaction diff --git a/changelog/unreleased/935-file-workspace.md b/changelog/unreleased/935-file-workspace.md new file mode 100644 index 000000000..c1c588fa1 --- /dev/null +++ b/changelog/unreleased/935-file-workspace.md @@ -0,0 +1,6 @@ +# Server file manager + +Expand folders, link directly to a file and switch between open editor tabs. +Edits autosave in order and failed saves remain available for retry during +the session. Select files and folders to download a ZIP, save an archive on +the server or delete them together. [Issue #935](https://github.com/GameplanePanel/Gameplane/issues/935) diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index af51c12f9..a7596069e 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -8099,6 +8099,14 @@ "defaultMessage": "Cancel", "description": "Files tab: button in the name prompt dialog that closes it without creating anything; keep it short" }, + "web.routes.tabs.files.close_file": { + "defaultMessage": "Close {path}", + "description": "Close a file tab after saving its edits." + }, + "web.routes.tabs.files.collapse": { + "defaultMessage": "Collapse {path}", + "description": "Hide a folder's children in the file tree." + }, "web.routes.tabs.files.config_yaml": { "defaultMessage": "config.yaml", "description": "Files tab: example placeholder in the new file name field; an example name, not a default" @@ -8107,6 +8115,10 @@ "defaultMessage": "Create", "description": "Files tab: confirm button of the new file and new folder prompts that creates the item; keep it short" }, + "web.routes.tabs.files.create_zip": { + "defaultMessage": "Create ZIP", + "description": "Create a ZIP of the selected files on the server." + }, "web.routes.tabs.files.delete": { "defaultMessage": "Delete", "description": "Files tab: red button that deletes the selected file or folder, and the confirm button of the delete dialog; keep it short" @@ -8127,6 +8139,14 @@ "defaultMessage": "This permanently removes the folder and everything inside it from the server's data volume. This action cannot be undone.", "description": "Files: body of the confirm dialog for deleting a folder" }, + "web.routes.tabs.files.delete_many_title": { + "defaultMessage": "Delete {count} items?", + "description": "Confirmation to delete multiple selected paths." + }, + "web.routes.tabs.files.delete_selected": { + "defaultMessage": "Delete selected", + "description": "Delete all selected files and folders." + }, "web.routes.tabs.files.discard_unsaved_changes": { "defaultMessage": "You have unsaved changes. Discard them and continue?", "description": "Files: confirm prompt shown before discarding unsaved file edits" @@ -8135,10 +8155,18 @@ "defaultMessage": "Download", "description": "Files tab: button that downloads the selected file to the user's computer; keep it short" }, + "web.routes.tabs.files.download_zip": { + "defaultMessage": "Download ZIP", + "description": "Download the selected paths as one ZIP archive." + }, "web.routes.tabs.files.empty_folder": { "defaultMessage": "Empty folder", "description": "Files tab: text shown in the file list when the current folder has no entries" }, + "web.routes.tabs.files.expand": { + "defaultMessage": "Expand {path}", + "description": "Show a folder's children in the file tree." + }, "web.routes.tabs.files.file_name": { "defaultMessage": "File name", "description": "Files tab: field label for the name of a new file in the new file prompt" @@ -8171,10 +8199,18 @@ "defaultMessage": "New folder", "description": "Files tab: button that opens the prompt to create a new folder, and the title of that prompt dialog; keep it short" }, + "web.routes.tabs.files.open_files": { + "defaultMessage": "Open files", + "description": "Accessible name for the open file tabs." + }, "web.routes.tabs.files.parent_directory": { "defaultMessage": "..", "description": "Files: row that opens the parent folder of the current folder" }, + "web.routes.tabs.files.partial_delete": { + "defaultMessage": "Deleted {count} of {total}. {errors}", + "description": "Reports how many selected paths were deleted and which paths failed." + }, "web.routes.tabs.files.path_separator": { "defaultMessage": "/", "description": "Files: slash drawn between breadcrumb segments" @@ -8195,6 +8231,14 @@ "defaultMessage": "Select a file to edit.", "description": "Files tab: empty-state text in the editor pane shown when no file is selected" }, + "web.routes.tabs.files.select_path": { + "defaultMessage": "Select {path}", + "description": "Select a path for file actions." + }, + "web.routes.tabs.files.selected_count": { + "defaultMessage": "{count} selected", + "description": "Number of checked file paths." + }, "web.routes.tabs.files.upload": { "defaultMessage": "Upload", "description": "Files tab: button that uploads a file to the current folder; shows a spinner while the upload runs; keep it short" @@ -8203,6 +8247,10 @@ "defaultMessage": "Working…", "description": "Files: label of the dialog submit button while the request is running" }, + "web.routes.tabs.files.zip_name": { + "defaultMessage": "Archive filename (.zip)", + "description": "Label for the name of a ZIP to save on the server." + }, "web.routes.tabs.logs.connected_waiting_for_output": { "defaultMessage": "Connected — waiting for output…", "description": "Logs: shown once the log stream is connected and no output has arrived yet" diff --git a/web/e2e/specs/fileWorkspace.spec.ts b/web/e2e/specs/fileWorkspace.spec.ts new file mode 100644 index 000000000..ed5578993 --- /dev/null +++ b/web/e2e/specs/fileWorkspace.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +test("opens an unlisted file directly and autosaves it in the local editor", async ({ page }) => { + test.skip(process.env.GAMEPLANE_E2E_TARGET === "live", "Uses the mock server volume."); + await page.goto("/servers/alpha?cluster=local&ns=gameplane-games&file=%2Fconfig%2Funlisted.txt"); + await expect(page.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true"); + const editor = page.locator(".monaco-editor"); + await expect(editor.locator(".view-line").first()).toContainText("# mock file body"); + const saved = page.waitForRequest(req => new URL(req.url()).pathname === "/servers/alpha/files/write"); + await editor.locator(".view-line").first().click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.insertText("motd=hello"); + const write = await saved; + expect(new URL(write.url()).searchParams.get("path")).toBe("/config/unlisted.txt"); + expect(new URL(write.url()).searchParams.get("namespace")).toBe("gameplane-games"); + expect(write.postData()).toBe("motd=hello"); + await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Close /config/unlisted.txt" }).click(); + await expect(page.getByText("Select a file to edit.")).toBeVisible(); + expect(new URL(page.url()).searchParams.get("file")).toBe("/config/"); +}); diff --git a/web/specs.md b/web/specs.md index 6f8520031..e18a267c4 100644 --- a/web/specs.md +++ b/web/specs.md @@ -310,7 +310,9 @@ Six of the nine ServerDetail tabs are rebuilt in this slice: - Folder rows carry a trash button (on row hover or focus on desktop, always visible on mobile) that opens the same delete confirmation without opening the folder - The new file/folder name prompt explains a rejected name inline ("Names can't contain "/" or be "." or "..".") and marks the input `aria-invalid`; Create stays disabled - Monaco editor unchanged - - File drafts bind to the selected path, resource target and selection generation. Selection clears content immediately and Save stays disabled until that file has loaded. Late reads and save completions cannot replace another selection's content or baseline, including reopening the same path. Saves capture their target, path, body and directory at click time; edits typed during a save remain dirty against the saved body. Changing server/namespace/cluster/UID starts a fresh file browser. + - The tree expands folders on demand. A server URL with a `file` query opens the path directly, even if a directory listing is truncated. A trailing slash browses that directory. + - Open files have separate tabs and drafts scoped by cluster, namespace, server name, UID and path. Edits autosave after a pause, with one write at a time per file. A failed write retains the draft for retry within the current session. Closing a dirty tab waits for saving. Deletion fences outstanding writes before removing a path and its descendant drafts. + - Check one or more paths to download a ZIP, create a ZIP in the current directory, or confirm deletion. The file manager identifies failed paths and keeps them selected. - New file uses POST `/files/create` with the encoded path, namespace/cluster scope, credentials and CSRF headers. The agent atomically refuses existing entries with 409; a conflict or unsupported older agent/gateway keeps the prompt open, shows the error and does not select a file or invalidate the list. Never fall back to `/files/write`; editor saves and uploads retain overwrite behavior. 6. **Players** (`web/src/routes/tabs/Players.tsx`, 386 lines) diff --git a/web/src/lib/endpoints.ts b/web/src/lib/endpoints.ts index 991a27686..925039058 100644 --- a/web/src/lib/endpoints.ts +++ b/web/src/lib/endpoints.ts @@ -657,7 +657,7 @@ export const ModuleBuilder = { type RequestClient = Pick, "api" | "url" | "raw" | "Captures" | "Shares">; -function makeResourceEndpoints(request: RequestClient) { +function makeResourceEndpoints(request: RequestClient, expectedUID?: string) { const api = request.api; const withCluster = (path: string, cluster?: string) => request.url(cluster ? withClusterParam(path, cluster) : path); const fetch = request.raw; @@ -934,7 +934,8 @@ function makeResourceEndpoints(request: RequestClient) { }; async function filesFetch(input: string, init: RequestInit = {}): Promise { - const res = await fetch(withCluster(input), { credentials: "include", ...init }); + const headers = { ...init.headers, ...(expectedUID && { "X-Gameplane-Server-UID": expectedUID }) }; + const res = await fetch(withCluster(input), { credentials: "include", ...init, headers }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new APIError(res.status, text); @@ -1007,6 +1008,13 @@ function makeResourceEndpoints(request: RequestClient) { body: fd, }); }, + archive: async (server: string, paths: string[], dest?: string, ns?: string): Promise => { + const query = new URLSearchParams(paths.map((path) => ["path", path])); + if (dest) query.set("dest", dest); + const res = await filesFetch(withNS(`${filesBase(server)}/archive?${query}`, ns), + dest ? { method: "POST", headers: csrfHeaders() } : {}); + if (!dest) return res.blob(); + }, downloadURL: (server: string, path: string, ns?: string) => withCluster(withNS(`${filesBase(server)}/download?path=${encodeURIComponent(path)}`, ns)), }; @@ -1028,10 +1036,10 @@ function makeResourceEndpoints(request: RequestClient) { } /** Capture cluster/namespace once before starting any asynchronous operation. */ -export function createResourceClient(scope: ResourceScope, signal?: AbortSignal): ResourceClient { +export function createResourceClient(scope: ResourceScope & {uid?: string}, signal?: AbortSignal): ResourceClient { const captured = Object.freeze({ ...scope }); return { - ...makeResourceEndpoints(createRequestClient(captured, signal)), + ...makeResourceEndpoints(createRequestClient(captured, signal), captured.uid), withSignal: (nextSignal: AbortSignal) => createResourceClient(captured, nextSignal), }; } diff --git a/web/src/lib/fileDrafts.test.ts b/web/src/lib/fileDrafts.test.ts new file mode 100644 index 000000000..540380597 --- /dev/null +++ b/web/src/lib/fileDrafts.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { FileDrafts } from "./fileDrafts"; + +describe("file drafts", () => { + it("retains a rejected draft for another tab and retries the same text", async () => { + const write = vi.fn().mockRejectedValueOnce(new Error("disk full")).mockResolvedValue(undefined); + const drafts = new FileDrafts(write); + drafts.open("/first", "before"); + drafts.edit("/first", "after"); + expect(await drafts.save("/first")).toBe(false); + drafts.open("/second", "other"); + drafts.open("/first", "before"); + expect(drafts.get("/first")).toMatchObject({ text: "after", saved: "before", error: "disk full" }); + expect(await drafts.save("/first")).toBe(true); + expect(write.mock.calls).toEqual([["/first", "after"], ["/first", "after"]]); + }); + + it("orders writes and blocks descendant writes while a folder is removed", async () => { + let finish!: () => void; + const write = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(undefined); + const drafts = new FileDrafts(write); + drafts.open("/dir/file", "initial"); + drafts.edit("/dir/file", "first"); + const pending = drafts.save("/dir/file"); + drafts.edit("/dir/file", "second"); + const deletion = vi.fn().mockResolvedValue(undefined); + const remove = drafts.remove("/dir", deletion); + expect(deletion).not.toHaveBeenCalled(); + finish(); + await pending; + await remove; + expect(deletion).toHaveBeenCalledOnce(); + expect(write.mock.calls).toEqual([["/dir/file", "first"]]); + expect(drafts.get("/dir/file")).toBeUndefined(); + }); +}); diff --git a/web/src/lib/fileDrafts.ts b/web/src/lib/fileDrafts.ts new file mode 100644 index 000000000..a8d66ec2e --- /dev/null +++ b/web/src/lib/fileDrafts.ts @@ -0,0 +1,126 @@ +interface Draft { + text: string; + saved: string; + error?: string; + deleting?: boolean; + pending?: Promise; + timer?: ReturnType; +} + +// Each workspace belongs to one authorized server incarnation and query client. +// Draft contents stay in this browser session and never enter persistent storage. +export class FileDrafts { + private readonly files = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly removing = new Set(); + private revision = 0; + private enabled = true; + + constructor(private readonly write: (path: string, text: string) => Promise) {} + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + snapshot = () => this.revision; + private changed() { + this.revision++; + this.listeners.forEach((listener) => listener()); + } + + paths() { return Array.from(this.files.keys()); } + get(path: string) { return this.files.get(path); } + open(path: string, text: string) { + if (!this.files.has(path) && ![...this.removing].some((root) => path === root || path.startsWith(root + "/"))) { + this.files.set(path, { text, saved: text }); + this.changed(); + } + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + for (const [path, draft] of this.files) { + clearTimeout(draft.timer); + if (enabled && !draft.error && draft.text !== draft.saved) this.schedule(path, draft); + } + } + + private schedule(path: string, draft: Draft) { + clearTimeout(draft.timer); + draft.timer = setTimeout(() => { void this.save(path); }, 1200); + } + + edit(path: string, text: string) { + const draft = this.get(path); + if (!draft || draft.deleting || !this.enabled || draft.text === text) return; + draft.text = text; + draft.error = undefined; + this.schedule(path, draft); + this.changed(); + } + + save(path: string): Promise { + const draft = this.get(path); + if (!draft || draft.deleting || !this.enabled) return Promise.resolve(false); + clearTimeout(draft.timer); + if (!draft.pending) { + draft.pending = this.flush(path, draft).finally(() => { + draft.pending = undefined; + this.changed(); + }); + this.changed(); + } + return draft.pending; + } + + private async flush(path: string, draft: Draft) { + draft.error = undefined; + try { + while (this.enabled && !draft.deleting && draft.saved !== draft.text) { + const body = draft.text; + await this.write(path, body); + draft.saved = body; + } + return draft.text === draft.saved; + } catch (err) { + clearTimeout(draft.timer); + draft.error = (err as Error).message; + return false; + } + } + + async close(path: string) { + const draft = this.get(path); + if (!draft || draft.deleting) return false; + if ((draft.pending || draft.text !== draft.saved) && !await this.save(path)) return false; + return draft.text === draft.saved && this.discard(path); + } + + discard(path: string) { + const draft = this.get(path); + if (!draft || draft.pending || draft.deleting) return false; + clearTimeout(draft.timer); + this.files.delete(path); + this.changed(); + return true; + } + + async remove(path: string, operation: () => Promise) { + this.removing.add(path); + const affected = [...this.files].filter(([key]) => key === path || key.startsWith(path + "/")); + for (const [, draft] of affected) { + draft.deleting = true; + clearTimeout(draft.timer); + } + this.changed(); + try { + await Promise.all(affected.map(([, draft]) => draft.pending)); + await operation(); + for (const [key] of affected) this.files.delete(key); + } finally { + this.removing.delete(path); + for (const [, draft] of affected) draft.deleting = false; + this.changed(); + } + } +} diff --git a/web/src/router/tree.tsx b/web/src/router/tree.tsx index 11f9f354b..737d337ad 100644 --- a/web/src/router/tree.tsx +++ b/web/src/router/tree.tsx @@ -64,11 +64,13 @@ const serverDetailRoute = new Route({ getParentRoute: () => appLayoutRoute, path: "/servers/$name", component: ServerDetailPage, - validateSearch: (search: Record): { ns?: string; cluster?: string } => { + validateSearch: (search: Record): { ns?: string; cluster?: string; file?: string } => { for (const key of ["cluster", "ns"] as const) { if (search[key] !== undefined && (typeof search[key] !== "string" || !search[key])) throw new Error(`Invalid server ${key}`); } - return { ns: search.ns as string | undefined, cluster: search.cluster as string | undefined }; + const file = search.file; + if (file !== undefined && (typeof file !== "string" || !file.startsWith("/") || file.split("/").some(part => part === "." || part === ".."))) throw new Error("Invalid file path"); + return { ns: search.ns as string | undefined, cluster: search.cluster as string | undefined, file: file as string | undefined }; }, }); diff --git a/web/src/routes/ServerDetail.tsx b/web/src/routes/ServerDetail.tsx index 4ed7961fd..645b7f0cd 100644 --- a/web/src/routes/ServerDetail.tsx +++ b/web/src/routes/ServerDetail.tsx @@ -79,11 +79,11 @@ const tabs: Array<{ key: TabKey; label: MessageDescriptor }> = [ export function ServerDetailPage() { const { name } = useParams({ from: "/app-layout/servers/$name" }); - const { ns, cluster = "local" } = useSearch({ from: "/app-layout/servers/$name" }); - return ; + const { ns, cluster = "local", file } = useSearch({ from: "/app-layout/servers/$name" }); + return ; } -function ResolveServerDetail({ target }: { target: ResourceTarget }) { +function ResolveServerDetail({ target, file }: { target: ResourceTarget; file?: string }) { const intl = useOptionalIntl(); const navigate = useNavigate(); const server = useQuery({ @@ -100,9 +100,10 @@ function ResolveServerDetail({ target }: { target: ResourceTarget }) { }); useEffect(() => { if (!target.namespace && server.data?.metadata.namespace) { - void navigate({ ...serverLink({ cluster: target.cluster, name: target.name, namespace: server.data.metadata.namespace }), replace: true }); + const link = serverLink({ cluster: target.cluster, name: target.name, namespace: server.data.metadata.namespace }); + void navigate({ ...link, search: { ...link.search, file }, replace: true }); } - }, [target.cluster, target.namespace, target.name, server.data?.metadata.namespace, navigate]); + }, [target.cluster, target.namespace, target.name, server.data?.metadata.namespace, navigate, file]); const retry = () => { void server.refetch(); void access.refetch(); }; if (server.error || access.error) return
; if (!server.data || !access.data) return
; @@ -112,12 +113,12 @@ function ResolveServerDetail({ target }: { target: ResourceTarget }) { } return ( - + ); } -function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { +function ServerDetailView({ lookupTarget, file }: { lookupTarget: ResourceTarget; file?: string }) { const intl = useOptionalIntl(); const target = useResourceTarget(lookupTarget); const clusterId = target.cluster; @@ -126,10 +127,20 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { const client = useResourceClient(); const { Servers } = client; const access = useResourceAccess(); - const [tab, setTab] = useState("overview"); + const [localTab, setTab] = useState("overview"); + const tab = file !== undefined ? "files" : localTab; const [settingsDirty, setSettingsDirty] = useState(false); const qc = useQueryClient(); const nav = useNavigate(); + function navigateFile(path?: string) { + const link = serverLink(target); + void nav({ ...link, search: { ...link.search, file: path } }); + } + function selectTab(next: TabKey) { + setTab(next); + if (next === "files" && file === undefined) navigateFile("/"); + else if (next !== "files" && file !== undefined) navigateFile(); + } const { data: me } = useMe(); const { data: gs } = useQuery({ @@ -315,7 +326,7 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { {intl.formatMessage({ id: "web.routes.server_detail.start", defaultMessage: "Start", description: "Server detail: header button that starts a stopped server; keep it short" })} )} {consoleAvailable && ( - )} {gs && ( @@ -336,7 +347,7 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) {