Skip to content

Commit 1c18596

Browse files
feat(pod-file-browser): inline preview for size-bounded text files (#1711)
## Description Adds a read-only inline viewer to the Pod filesystem browser (issue #1581). A **Preview** icon on each regular-file row opens the file in Monaco (read-only, language auto-detected). Downloads remain unchanged. **Assigned by @nadaverell** after the [assumptions comment on #1581](#1581 (comment)); the guidance in the [assign comment](#1581 (comment)) to \"probably want curated error messages rather than dumping raw error\" is why every failure state on this PR is a named error code, not a leaked stderr line. ### Backend (`internal/server/copy.go`) - New `GET /api/pods/{namespace}/{name}/file` endpoint, registered in the 60s-timeout group (preview is capped at 1 MiB — never legitimately > 60s over a real cluster link). - Reuses `openPodFileForPreview` — a preview twin of `openPodFileForRequest` — so the tar/cat plumbing, drain guard, and error classification are the battle-tested ones already in use for downloads. The only structural change to the file: everything preview-related is appended at the bottom. - **Size gate fires before any body is buffered**: a file whose tar header declares > 1 MiB is rejected with a 413 `file_too_large` code and the real size on the wire, so the UI can say \"12.3 MiB, too large\" instead of showing a truncated preview. - **Layered content sniff** via new pure helper `classifyPreviewBytes`: 1. Empty → `empty_file` (success shape with an explicit empty state on the UI). 2. `http.DetectContentType` accepts `text/*`, `application/json`, `application/xml`, `application/x-yaml`. 3. Otherwise, a NUL-byte scan of the first 8 KiB catches YAML/nginx-conf-shaped files that `DetectContentType` calls `application/octet-stream`. 4. `utf8.Valid` — non-UTF-8 falls to `binary_file` with a distinct message. - **Structured error envelope**: every failure returns `{error, code, size?, mimeType?}` — never a raw stderr string. Codes: `file_too_large`, `binary_file`, `empty_file`, `not_a_regular_file`, `not_found`, `permission_denied`, `no_shell`, `container_missing_tools`, `read_failed`. Backed by a single `writePodFilePreviewError` helper so the wire shape has one owner. - **RBAC/capability**: same shape as the download handler — `requireConnected` + apiserver-enforced RBAC on pod-exec via `getClientForRequest`. No new authorization path, per issue. ### Frontend (`web/src/components/resources/PodFilePreviewModal.tsx`) New portal-based modal, nested above the existing filesystem browser. ESC on the preview returns to the filesystem list (nested portal, stopPropagation). - **Monaco** in read-only mode. Language auto-detected from filename (`.yaml`/`.yml`/`.json`/`.xml`/`.md`/`.sh`/`.conf`/`.ini`/…); unknown extensions fall to `plaintext`. Dark/light theme tracks the document class the same way `YamlEditor` does. - **Curated states per code** — the primary description never leaks the raw server message: | Code | Actions | Notes | |----------------------------|--------------------|-----------------------------------------------------------------| | `file_too_large` | Download | Names the real size (\"This file is 12.3 MiB…\") | | `binary_file` | Download | Names the detected mime type | | `empty_file` | — | Distinct \"is empty\" state — not an error | | `not_a_regular_file` | — | \"directory, device, or pipe\" | | `not_found` | Retry | \"may have been rotated or deleted since the listing\" | | `permission_denied` | — | Suggests a different container | | `no_shell` | — | \"likely distroless or scratch-based\" | | `container_missing_tools` | Download | Falls back to the raw-cat download path | | `network_error` | Retry + Details | Raw message hidden behind a \"Technical details\" disclosure | | `read_failed` (default) | Download + Retry | Raw message hidden behind Technical details | - **Wired into `PodFilesystemModal`** as a new `Eye` icon on each regular-file row — symlinks stay download-only for parity with existing behavior. ## Tests - `internal/server/preview_test.go`: - 6 pure-classifier tests (`classifyPreviewBytes`) covering text, JSON, YAML-via-NUL fallback, NUL binary, non-UTF-8, empty. - 7 handler-level tests through the real router and the existing `newExecutor` fake seam: happy path, oversized (asserts size on the wire), binary, empty (success shape), permission denied, missing file, bad path. - `web/src/components/resources/PodFilePreviewModal.test.tsx`: - Locks the action affordances per code (Download vs Retry). - Iterates every code and asserts the raw stderr never appears in the primary title/description. - `detectLanguage` unit table. ``` $ CGO_ENABLED=0 go test ./internal/server/... ok github.com/skyhook-io/radar/internal/server 14.1s $ cd web && npx vitest run src/components/resources/PodFilePreviewModal.test.tsx Test Files 1 passed (1) Tests 26 passed (26) $ make tsc green ``` Full `web` vitest sweep: 84 files / 932 tests pass. 3 files failed (all `e2e/*.spec.ts` Playwright specs that need a browser + one flaky `authRedirect` timing test) — verified against upstream HEAD; unrelated to this change. ## Type of change - [x] New feature (non-breaking change that adds functionality) ## How has this been tested? - [x] Added/updated unit tests (backend and frontend) - [x] Tested locally with kind — user smoke on a real cluster covering: preview of a YAML config (renders inline), preview of a binary (`/usr/bin/…` → binary_file curated state), preview of an empty file, permission-denied path, and download from within each error state. ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my code - [x] I have added comments where necessary - [x] My changes generate no new warnings ## Related issues Fixes #1581 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New pod-exec read path buffers up to 1 MiB per preview request, but authorization matches existing downloads and failures are bounded by explicit size and content gates. > > **Overview** > Adds a **read-only inline file preview** to the pod filesystem browser: clicking a regular file (under **1 MiB**) replaces the listing with a Monaco viewer, with **back navigation**, **ESC** stepping out of preview first, and a shared **download** path from the preview header and tree rows. > > The backend exposes **`GET /api/pods/{namespace}/{name}/file`** (alongside the existing file list routes), reusing the same tar/cat pod-exec plumbing as downloads but returning JSON. Preview **rejects files over 1 MiB before reading the body**, classifies content as text vs binary (sniff + UTF-8), and returns **stable error codes** (`file_too_large`, `binary_file`, `permission_denied`, etc.) so the UI can show curated fallows instead of raw stderr. > > The frontend **`PodFilePreview`** maps those codes to operator-facing copy with Download/Retry affordances; oversized files are **blocked in the listing** with a tooltip. **`ensureMonacoRuntime`** and extra Monaco basic-language tokenizers support syntax highlighting by filename (JSON highlighted via YAML tokenizer). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 84bca19. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Nadav Erell <nadaverell@gmail.com>
1 parent 3bf7b43 commit 1c18596

9 files changed

Lines changed: 1355 additions & 67 deletions

File tree

‎internal/server/copy.go‎

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bufio"
66
"bytes"
77
"context"
8+
"encoding/json"
89
"errors"
910
"fmt"
1011
"io"
@@ -18,6 +19,7 @@ import (
1819
"sync"
1920
"sync/atomic"
2021
"time"
22+
"unicode/utf8"
2123

2224
"github.com/go-chi/chi/v5"
2325
corev1 "k8s.io/api/core/v1"
@@ -1000,3 +1002,295 @@ func isCommandNotFound(errMsg string) bool {
10001002
}
10011003
return false
10021004
}
1005+
1006+
// Pod file preview — an inline read-only viewer for the filesystem browser.
1007+
// Backed by the same tar/cat plumbing as download; the shape below is what
1008+
// keeps the two apart: preview refuses oversized and non-text files up front
1009+
// so the UI can present a curated fallback instead of streaming binary bytes
1010+
// into a text editor.
1011+
1012+
const (
1013+
// podFilePreviewByteCap bounds the buffered read. A file larger than this
1014+
// is rejected before any body arrives — never truncated silently.
1015+
podFilePreviewByteCap = 1 << 20 // 1 MiB
1016+
1017+
// podFilePreviewSniffBytes is how much of the head goes through
1018+
// http.DetectContentType and the NUL scan. Enough to catch every real-world
1019+
// config format and cheap enough to run on every request.
1020+
podFilePreviewSniffBytes = 8 << 10 // 8 KiB
1021+
)
1022+
1023+
// Error codes for the preview endpoint. The frontend switches on these to
1024+
// render curated fallback UI (Download button, retry, container-switch hint).
1025+
// Never renamed casually — they are wire contract.
1026+
const (
1027+
previewCodeFileTooLarge = "file_too_large"
1028+
previewCodeBinaryFile = "binary_file"
1029+
previewCodeEmptyFile = "empty_file"
1030+
previewCodeNotARegularFile = "not_a_regular_file"
1031+
previewCodeNotFound = "not_found"
1032+
previewCodePermissionDenied = "permission_denied"
1033+
previewCodeNoShell = "no_shell"
1034+
previewCodeContainerMissingTools = "container_missing_tools"
1035+
previewCodeReadFailed = "read_failed"
1036+
)
1037+
1038+
// podFilePreviewResponse is the success shape. `code` is set on the empty-file
1039+
// success (empty_file) so the frontend can render an explicit empty state
1040+
// instead of a blank editor that looks like a failed load.
1041+
type podFilePreviewResponse struct {
1042+
Content string `json:"content"`
1043+
Size int64 `json:"size"`
1044+
Truncated bool `json:"truncated"`
1045+
MimeType string `json:"mimeType"`
1046+
Encoding string `json:"encoding"`
1047+
Code string `json:"code,omitempty"`
1048+
}
1049+
1050+
// podFilePreviewErrorResponse is the curated-error shape. `size` and
1051+
// `mimeType` are emitted when known so the UI can say "12.3 MiB, too large"
1052+
// or "detected type: application/x-executable" without reparsing the message.
1053+
type podFilePreviewErrorResponse struct {
1054+
Error string `json:"error"`
1055+
Code string `json:"code"`
1056+
Size int64 `json:"size,omitempty"`
1057+
MimeType string `json:"mimeType,omitempty"`
1058+
}
1059+
1060+
// writePodFilePreviewError is writeError plus the preview error shape. Kept
1061+
// in one place so a wire change never requires chasing every handler branch.
1062+
func (s *Server) writePodFilePreviewError(w http.ResponseWriter, status int, code, message string, size int64, mimeType string) {
1063+
w.Header().Set("Content-Type", "application/json")
1064+
w.WriteHeader(status)
1065+
payload := podFilePreviewErrorResponse{Error: message, Code: code, Size: size, MimeType: mimeType}
1066+
if err := json.NewEncoder(w).Encode(payload); err != nil {
1067+
log.Printf("Failed to encode preview error response: %v", err)
1068+
}
1069+
}
1070+
1071+
// previewClassification is the outcome of the sniff. `code` is empty on a
1072+
// clean text hit; when set, the file is not previewable and the caller
1073+
// forwards the code and mime type to the client.
1074+
type previewClassification struct {
1075+
code string // empty on success
1076+
mimeType string
1077+
message string
1078+
}
1079+
1080+
// classifyPreviewBytes decides whether a byte slice may be shown in the
1081+
// inline viewer. Layered so the common cases end early:
1082+
//
1083+
// 1. Empty → the caller renders an explicit "empty file" state.
1084+
// 2. DetectContentType hit on text/*, application/json/xml/x-yaml, etc.
1085+
// 3. NUL-byte scan of the first 8 KiB — catches text whose head sniffs as
1086+
// application/octet-stream because of a stray C0 control byte (record
1087+
// separators, ^A field delimiters) but which is obviously text.
1088+
// 4. UTF-8 validation — non-UTF-8 falls to the binary fallback rather than
1089+
// rendering mojibake.
1090+
func classifyPreviewBytes(data []byte) previewClassification {
1091+
if len(data) == 0 {
1092+
return previewClassification{code: previewCodeEmptyFile, mimeType: "text/plain; charset=utf-8"}
1093+
}
1094+
1095+
head := data
1096+
if len(head) > podFilePreviewSniffBytes {
1097+
head = data[:podFilePreviewSniffBytes]
1098+
}
1099+
1100+
mimeType := http.DetectContentType(head)
1101+
mimeBase := mimeType
1102+
if i := strings.IndexByte(mimeBase, ';'); i >= 0 {
1103+
mimeBase = strings.TrimSpace(mimeBase[:i])
1104+
}
1105+
1106+
looksTextual := strings.HasPrefix(mimeBase, "text/") ||
1107+
mimeBase == "application/json" ||
1108+
mimeBase == "application/xml" ||
1109+
mimeBase == "application/x-yaml" ||
1110+
mimeBase == "application/yaml"
1111+
1112+
// http.DetectContentType calls text with a C0 control byte in its head
1113+
// (record separators, ^A field delimiters) application/octet-stream — a
1114+
// NUL-byte scan rescues these as text/plain. The fallback is deliberately
1115+
// narrow: rescuing every non-textual MIME would let known binaries (PDF,
1116+
// ZIP, ELF) through whenever their first 8 KiB happen to lack a NUL,
1117+
// which is common in ASCII-headered formats and is not a defence.
1118+
if !looksTextual && mimeBase == "application/octet-stream" {
1119+
if bytes.IndexByte(head, 0) == -1 {
1120+
mimeType = "text/plain; charset=utf-8"
1121+
looksTextual = true
1122+
}
1123+
}
1124+
1125+
if !looksTextual {
1126+
return previewClassification{
1127+
code: previewCodeBinaryFile,
1128+
mimeType: mimeType,
1129+
message: fmt.Sprintf("This file appears to be binary (detected type: %s). Download to view its contents.", mimeType),
1130+
}
1131+
}
1132+
1133+
if !utf8.Valid(data) {
1134+
return previewClassification{
1135+
code: previewCodeBinaryFile,
1136+
mimeType: mimeType,
1137+
message: "This file is not valid UTF-8 and cannot be previewed. Download to view.",
1138+
}
1139+
}
1140+
1141+
return previewClassification{mimeType: mimeType}
1142+
}
1143+
1144+
// openPodFileForPreview is the preview-side parallel to openPodFileForRequest.
1145+
// It returns the tar/cat plumbing verbatim; the difference is that error
1146+
// classification is handed back to the handler so it can be rendered as a
1147+
// curated code plus a message, not squashed to a plain-text writeError.
1148+
//
1149+
// nil error → src is non-nil and callable; non-nil error → src is nil.
1150+
func (s *Server) openPodFileForPreview(r *http.Request) (*podFileSource, *podFileOpenError, int, string) {
1151+
namespace := chi.URLParam(r, "namespace")
1152+
podName := chi.URLParam(r, "name")
1153+
container := r.URL.Query().Get("container")
1154+
filePath := r.URL.Query().Get("path")
1155+
if filePath == "" {
1156+
return nil, nil, http.StatusBadRequest, "path parameter is required"
1157+
}
1158+
1159+
filePath = path.Clean(filePath)
1160+
if !isDownloadablePath(filePath) {
1161+
return nil, nil, http.StatusBadRequest, fmt.Sprintf("Not a file: %s", filePath)
1162+
}
1163+
1164+
client := s.getClientForRequest(r)
1165+
config := s.getConfigForRequest(r)
1166+
if client == nil || config == nil {
1167+
return nil, nil, http.StatusServiceUnavailable, "cluster client not available — check cluster connection"
1168+
}
1169+
1170+
src := &podFileSource{
1171+
namespace: namespace,
1172+
podName: podName,
1173+
filePath: filePath,
1174+
name: path.Base(filePath),
1175+
}
1176+
1177+
stream, openErr := s.openPodFileWithTar(r.Context(), client, config, namespace, podName, container, filePath)
1178+
if openErr != nil && openErr.commandMissing && !openErr.shellMissing {
1179+
stream, openErr = s.openPodFileWithCat(r.Context(), client, config, namespace, podName, container, filePath)
1180+
}
1181+
if openErr == nil {
1182+
src.stream = stream
1183+
src.size = stream.size
1184+
return src, nil, 0, ""
1185+
}
1186+
1187+
// commandMissing at this point means BOTH tar and cat are unavailable
1188+
// through /bin/sh. The download handler falls back to a bare `cat`
1189+
// invocation; for preview we surface the shape instead — a container
1190+
// without tools is a curated end state, not a silent degradation.
1191+
return nil, openErr, 0, ""
1192+
}
1193+
1194+
// classifyOpenErrorForPreview maps the tar/cat classifier to a preview
1195+
// error code and HTTP status. Message text is taken from openErr.message,
1196+
// which the classifier already wrote in operator-facing language.
1197+
func classifyOpenErrorForPreview(openErr *podFileOpenError) (int, string) {
1198+
switch {
1199+
case openErr.notFound:
1200+
return http.StatusNotFound, previewCodeNotFound
1201+
case openErr.notAFile:
1202+
return http.StatusBadRequest, previewCodeNotARegularFile
1203+
case openErr.shellMissing:
1204+
return http.StatusNotImplemented, previewCodeNoShell
1205+
case openErr.commandMissing:
1206+
return http.StatusNotImplemented, previewCodeContainerMissingTools
1207+
}
1208+
// The classifier writes "Permission denied: ..." into message for both
1209+
// tar and cat variants; matching on the shared prefix keeps the two
1210+
// callers aligned without a second parse of the stderr blob.
1211+
if strings.HasPrefix(openErr.message, "Permission denied") {
1212+
return http.StatusForbidden, previewCodePermissionDenied
1213+
}
1214+
return http.StatusInternalServerError, previewCodeReadFailed
1215+
}
1216+
1217+
// handlePodFilePreview reads a size-bounded text file out of a container
1218+
// and returns it in a JSON envelope for the inline viewer.
1219+
// GET /api/pods/{ns}/{name}/file?container=X&path=/some/file
1220+
func (s *Server) handlePodFilePreview(w http.ResponseWriter, r *http.Request) {
1221+
if !s.requireConnected(w) {
1222+
return
1223+
}
1224+
1225+
src, openErr, badStatus, badMsg := s.openPodFileForPreview(r)
1226+
if badStatus != 0 {
1227+
s.writePodFilePreviewError(w, badStatus, previewCodeReadFailed, badMsg, 0, "")
1228+
return
1229+
}
1230+
if openErr != nil {
1231+
status, code := classifyOpenErrorForPreview(openErr)
1232+
if status >= 500 {
1233+
// Only the truly-unexpected shapes log — the operator-facing ones
1234+
// (not found, permission denied, no shell) are noise in the log.
1235+
namespace, podName, filePath := chi.URLParam(r, "namespace"), chi.URLParam(r, "name"), r.URL.Query().Get("path")
1236+
log.Printf("[copy] Failed to open preview %s/%s path=%s: %v, stderr: %s", namespace, podName, filePath, openErr.err, openErr.stderr)
1237+
errorlog.Record("copy", "error", "Failed to open preview %s/%s path=%s: %v", namespace, podName, filePath, openErr.err)
1238+
}
1239+
s.writePodFilePreviewError(w, status, code, openErr.message, 0, "")
1240+
return
1241+
}
1242+
1243+
// Size gate BEFORE any body read. Reject up front so the tar/cat drain
1244+
// guard is closed cleanly without pulling megabytes we would throw away.
1245+
if src.size > podFilePreviewByteCap {
1246+
if closeErr := src.Close(); closeErr != nil {
1247+
log.Printf("[copy] Failed to close preview stream after oversize %s/%s path=%s: %v", src.namespace, src.podName, src.filePath, closeErr)
1248+
}
1249+
s.writePodFilePreviewError(w, http.StatusRequestEntityTooLarge, previewCodeFileTooLarge,
1250+
fmt.Sprintf("File is %d bytes; preview is limited to %d bytes. Download to view.", src.size, podFilePreviewByteCap),
1251+
src.size, "")
1252+
return
1253+
}
1254+
1255+
// Both framings stop at the declared size, so the +1 never yields a byte;
1256+
// it makes ReadAll take the final EOF from the stream, which is what marks
1257+
// the read complete so Close waits for the command instead of aborting it.
1258+
// A short read is caught downstream by podFileStream.Read.
1259+
body, readErr := io.ReadAll(io.LimitReader(src, src.size+1))
1260+
// Close before answering — mirror the download handler, which learns
1261+
// whether the command actually finished from Close(). Preview has already
1262+
// buffered the whole body, so there is no wire-shape reason to defer.
1263+
closeErr := src.Close()
1264+
if readErr != nil {
1265+
log.Printf("[copy] Failed to read preview %s/%s path=%s: %v", src.namespace, src.podName, src.filePath, readErr)
1266+
errorlog.Record("copy", "error", "Failed to read preview %s/%s path=%s: %v", src.namespace, src.podName, src.filePath, readErr)
1267+
s.writePodFilePreviewError(w, http.StatusInternalServerError, previewCodeReadFailed,
1268+
fmt.Sprintf("Failed to read file: %v", readErr), 0, "")
1269+
return
1270+
}
1271+
if closeErr != nil {
1272+
// Every declared byte arrived, so the buffered body still stands; the
1273+
// command reported trouble on the way out (an unreadable region, a
1274+
// file rewritten under the read), which belongs in the log next to
1275+
// the download handler's equivalent, not squashed as JSON success.
1276+
log.Printf("[copy] Failed to close preview stream after full read %s/%s path=%s: %v", src.namespace, src.podName, src.filePath, closeErr)
1277+
errorlog.Record("copy", "warning", "reading %s/%s path=%s ended with an error after the file arrived in full: %v", src.namespace, src.podName, src.filePath, closeErr)
1278+
}
1279+
1280+
classification := classifyPreviewBytes(body)
1281+
if classification.code == previewCodeBinaryFile {
1282+
s.writePodFilePreviewError(w, http.StatusUnsupportedMediaType, previewCodeBinaryFile,
1283+
classification.message, int64(len(body)), classification.mimeType)
1284+
return
1285+
}
1286+
1287+
resp := podFilePreviewResponse{
1288+
Content: string(body),
1289+
Size: int64(len(body)),
1290+
Truncated: false,
1291+
MimeType: classification.mimeType,
1292+
Encoding: "utf-8",
1293+
Code: classification.code, // empty on normal text, "empty_file" on 0-byte
1294+
}
1295+
s.writeJSON(w, resp)
1296+
}

0 commit comments

Comments
 (0)