Unverified Commit 2c98e8b2 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

perf(panel): speed up file browsing and preserve loading forms

parent 4d8e9af2
Loading
Loading
Loading
Loading
+6 −1
Changes for cmd/felis/api.go: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -342,11 +342,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
	// orphaned (the index is in memory), so the stage starts empty.
	var files api.FileEditor
	var fileStage *fileedit.Stage
	var fileBrowser *fileedit.Browser
	if felisImage != "" {
		fcfg := fileEditConfig(cfg, felisImage)
		fcfg.ResolveWorld = worldResolver
		fileBrowser = &fileedit.Browser{BaseURL: internalAPIBaseURL()}
		runner := fileedit.NewK8sRunner(clientset)
		runner.Browser = fileBrowser
		files = &fileedit.Editor{
			Runner: fileedit.NewK8sRunner(clientset),
			Runner: runner,
			Config: fcfg,
		}
		fileStage = &fileedit.Stage{Dir: fileStagingDir()}
@@ -416,6 +420,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		RestoreChains: jobStatus,
		Files:         files,
		FileStage:     fileStage,
		FileBrowser:   fileBrowser,
		// The file Job fetches an upload from here; it runs in the minecraft
		// namespace, where the internal face is reachable like it is for the login
		// gate.
+11 −0
Changes for cmd/felis/files.go: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("files", flag.ContinueOnError)
	fs.SetOutput(stderr)
	op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip")
	browseURL := fs.String("browse-url", "", "internal command channel for a read-only file browser")
	path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
	worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
	expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
@@ -53,6 +54,16 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
		return 2
	}

	if *browseURL != "" {
		limitHeapToCgroup()
		ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
		defer stop()
		if err := fileedit.Browse(ctx, *worldsRoot, *browseURL, os.Getenv(fileedit.BrowserTokenEnv)); err != nil {
			fmt.Fprintf(stderr, "felis files: %v\n", err)
			return 1
		}
		return 0
	}
	if *op == "" {
		fmt.Fprintln(stderr, "felis files: --op is required")
		return 2
+50 −0
Changes for docs/openapi.yaml: 50 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1625,6 +1625,56 @@ paths:
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/internal/file-browser/{id}:
    post:
      tags: [files]
      operationId: internalFileBrowser
      summary: Exchange a read-only file Job's result for its next command.
      description: >-
        Internal face only. A random bearer token scopes the worker to one
        world and a four-minute session; idle workers exit after 45 seconds.
        The first request sends an empty object. Later requests return the
        previous command's id and result or error. This endpoint dispatches
        only reads already authorized by the external file API.
      x-felis-face: [internal]
      x-felis-tier: public
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: Authorization, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                result: { type: object }
                error: { type: string }
      responses:
        '200':
          description: The next authorized read command.
          content:
            application/json:
              schema:
                type: object
                required: [id, op, path]
                properties:
                  id: { type: string }
                  op: { type: string, enum: [list, read] }
                  path: { type: string }
        '204':
          $ref: '#/components/responses/NoContent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/internal/exports/{id}:
    put:
      tags: [backups]
+12 −2
Changes for docs/troubleshooting.md: 12 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -3170,8 +3170,18 @@ for 10 seconds (the Free plan's limits).
## 18. Server files: a change or an upload is refused

The panel's Files page is for the server's owner or an admin, and only while
the server is fully stopped. Each call runs a one-shot `felis files` Job in the
`minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and
the server is fully stopped. Reads reuse a read-only `felis files` Job for the
same world, avoiding Pod startup for every folder and file. The worker pulls
commands from the existing internal API with a random, world-scoped token;
each request still passes the owner/admin and stopped gates. It exits after
45 idle seconds or four minutes total; at most four workers exist per API
process, with one-shot reads used at capacity. No file contents are cached.
The panel caches directory listings for 30 seconds; Refresh, a write, upload
or restore invalidates them. The text editor highlights common config formats
and preserves CRLF; binary and oversized files offer download instead.

Changes still run one-shot Jobs in the `minecraft` namespace, labelled
`app.kubernetes.io/managed-by=felis-files` and
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload|unzip>`.
A listing or a read holds nothing. Every change (a save, a new file or folder,
a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a
+3 −0
Changes for internal/api/api.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -99,6 +99,7 @@ type API struct {
	// InternalBaseURL is where that Job reaches felis-api's internal face to do so
	// (handleUploadFile). Uploads report 503 unless both are set.
	FileStage       *fileedit.Stage
	FileBrowser     *fileedit.Browser
	InternalBaseURL string

	// Exporter starts the Job behind a world or backup download (exports.go),
@@ -476,6 +477,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
		// with the upload is the check (handlers_files.go).
		{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
		{Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded},
		// Read-only file Jobs pull commands with their own scoped bearer token.
		{Method: "POST", Pattern: "/api/v1/internal/file-browser/{id}", Public: true, h: a.handleInternalFileBrowser},

		// An export Job's archive, held open until the owner's browser downloads
		// it. Public for the same reason as file uploads: the Job holds no service
Loading