mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
1b96f3b9803f37ae6f120e1bb0181ae67f88751f
15 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d8b6f8cf3b |
feat(stack-files): force-text override for misidentified binary files (#1215)
* feat(stack-files): force-text override for misidentified binary files
The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.
readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.
SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.
Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.
* fix(stack-files): guard the binary-override path against oversized files
The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.
Two reinforcing changes:
- Initial load now checks result.oversized before result.binary, so a
file that is both oversized and has binary bytes in the 8 KB probe
shows the Download panel rather than the binary panel. The size
signal stays in front of the operator and the override button never
surfaces for a file that cannot be safely opened inline.
- The handleForceText handler now respects result.oversized on the
refetch and transitions to the Download panel instead of clearing
isBinary and copying result.content ?? '' into Monaco.
Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.
Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
|
||
|
|
fcf2222604 |
feat(stack-files): cap directory listings at 1000 + add file-tree filter (#1208)
* feat(stack-files): cap directory listings at 1000 + add file-tree filter
The file-tree route returned every entry in a directory unbounded.
A logs/ or data/ subfolder with rotated artifacts could produce a
multi-megabyte response and a frontend cap at 500 entries silently
hid the rest with no way for the user to find a specific file.
The list route now caps the response at 1000 entries (the audit's
recommended bound), advertises the unfiltered total via
X-Total-Count, and sets X-Truncated when truncation happened. The
service exposes both a bare-array listStackDirectory (unchanged
contract for callers that just want the array) and a paginated
listStackDirectoryPage that returns {entries, total, truncated}.
The FileTree now offers a search input above the scroll area that
filters loaded entries by name (case-insensitive substring). Clearing
the filter restores the full listing. A non-matching filter shows a
short hint instead of an empty pane. The client-side MAX_ENTRIES
matches the server cap so a perfectly-sized directory never shows
the truncation hint.
* fix(stack-files): filter keeps parent dirs when loaded descendants match
The original filter applied per-render-level inside renderEntries, so a
parent directory whose name did not match was filtered out even when one
of its already-loaded children did. The match was then unreachable: the
parent had been removed from the visible list and its children never got
a chance to render.
Compute matching-descendant once per directory by walking the loaded
dirContents map (no extra fetch, bounded by what the user already
expanded). Keep ancestors of any match in the visible list. Auto-expand
those ancestors for the duration of the filter so the match comes into
view without a manual click on every parent.
Filter scope is still 'what is already loaded'; unexpanded subtrees do
not contribute to ancestor-keep until the user expands them. Two new
tests pin both behaviours.
|
||
|
|
ea002cd9a0 |
feat(stack-files): drag-and-drop upload zone (#1207)
* feat(stack-files): drag-and-drop upload zone The Files-tab dropzone was a click-only button. Drag-and-drop is the standard file-manager affordance and the audit doc named its absence as a known gap. The same dropzone now accepts file drops, gates the affordance on canEdit, and reuses the existing handleFile pipeline so all the existing rules apply: 25 MB cap, server-side path validation, multi-file drops rejected up front with a clear toast (the upload route only handles one file per request). dragenter+dragover light the zone in the brand color and swap the label to "Drop to upload"; dragleave honours bubbling from child nodes so the highlight does not flicker when the cursor crosses an inner icon. Drag events without a Files payload (text selection, image drag from another page) are explicitly ignored so the zone does not light up for non-file content. * test(stack-files): widen upload-zone E2E selector to match new label The drag-drop work renamed the dropzone label from 'Upload file' to 'Upload or drop file' (and 'Drop to upload' during hover). The E2E test in stack-files.spec.ts filtered on the literal /upload file/i, which no longer matches the new label and the test timed out at the visibility assertion. The hidden file input's aria-label='Upload file' was deliberately preserved, so every other locator in the spec (input[aria-label], getByLabel) keeps working. Only the role='button' filter needed the update. Widen the regex to /upload/i so future copy edits do not re-break this assertion. |
||
|
|
4964320f50 |
fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag PUT /api/stacks/:name/files/content previously did a blind write; two operators editing the same script lost one of the saves with no warning. The compose-file editor already had mtime optimistic concurrency (PR #1183); this brings the file-explorer write path to the same shape. GET /files/content now also returns mtimeMs and sets a weak ETag header derived from the stat. The matching PUT reads If-Match, asks FileSystemService.writeStackFileIfUnchanged to compare against the live mtime, and returns 412 PRECONDITION_FAILED with the current content and mtime when the stale-write check fails. Successful writes echo a fresh ETag so the client can pin the next save without re-GET. readStackFile and writeStackFileIfUnchanged each open the file once and stat+read through the same handle so the mtime returned to the client matches the bytes that were sent, even if the file is replaced between the two operations. PUT without If-Match still succeeds (backward compatibility with scripted clients that do not roundtrip the ETag). FileViewer now sends the loaded mtime on save, updates its local mtime from the success response, and on FileConflictError adopts the server snapshot as the new baseline so the user's follow-up edit-and-save does not loop on the same precondition. * fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict Two follow-ups from code review on the prior commit: - writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is set and the target has been deleted. The caller was editing a file that no longer exists; silently writing the buffer to the void is wrong. The client adopts the empty snapshot as 'file is gone, start over' and the user keeps control of what to save next. - The FileViewer conflict handler no longer overwrites the user's typed buffer with the server snapshot. It updates the baseline so the next save sends the fresh mtime, then leaves the editor content alone. The user sees their edits, the Save button stays enabled, and a follow-up click applies their changes on top of the new server version without silently destroying what they typed. * fix(api): preserve default headers when caller supplies a headers field apiFetch built defaultOptions.headers by merging Content-Type, x-node-id, and the caller's headers, but then spread the unmodified fetchOptions over defaultOptions at the outer level. The spread overwrote the merged headers with the caller's bare headers, silently dropping Content-Type on every request that supplied any custom header. This was latent until the file-explorer save path started sending an If-Match header. The Express body parser refused the PUT without Content-Type, the route returned 400, the editor showed an error toast instead of the success toast, and the Playwright save assertion timed out. Destructure headers out of fetchOptions before the outer spread so the already-merged defaultOptions.headers survives. Add api.test.ts with four regression cases pinning Content-Type, the If-Match merge, x-node-id presence when active, and localOnly skip. |
||
|
|
3e56696c91 |
fix(stack-files): prompt before discarding unsaved edits on file switch (#1203)
FileViewer tracks dirty state via content !== originalContent, but StackFileExplorer would swap selectedPath on every tree click and silently drop the buffered edit. A user editing a script and clicking a sibling file lost their work with no warning. FileViewer now exposes onDirtyChange so the explorer learns when the viewer is dirty. StackFileExplorer intercepts the tree-node click: if dirty, the next selection is stashed and a ConfirmModal asks whether to discard. Confirm applies the stash, Cancel keeps the current file. Clicking the already-selected file is a no-op (no spurious prompt). The dirty signal is reported via a ref so future consumers passing an inline callback identity each render do not retrigger the unmount cleanup effect. |
||
|
|
c8b095b887 |
fix(stack-files): confirm before overwriting an existing upload target (#1204)
* fix(stack-files): confirm before overwriting an existing upload target Same-name uploads previously truncated the existing file silently. A user dragging a file with a name that matched an in-place file destroyed the original with no warning and no undo. The upload route now reads ?overwrite=0|1. When the flag is not set and the target already exists, the server returns 409 FILE_EXISTS and the original file is untouched. The frontend opens a confirm dialog and retries with overwrite=1 on the user's approval; cancel keeps the original. A new pathExists helper on FileSystemService performs the existence check through the same path-resolution barrier as the write so a malicious relPath cannot bypass the conflict check. UploadConflictError is exported so callers can distinguish the conflict case from generic upload failures without parsing error strings. * fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check |
||
|
|
535023b350 |
feat(files): open stack file explorer to every tier (#1144)
* feat(files): open stack file explorer to every tier
Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.
The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.
* docs(files): treat download as a read action, not a write
Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.
* test(e2e): align stack-files spec with the new tier rule
The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.
Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
|
||
|
|
69b6ac1f3b |
fix: harden stack file explorer operations (#1028)
* fix: harden stack file explorer operations * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories |
||
|
|
0c3ce4b224 | feat: implement file explorer context menus and dialogs (#934) | ||
|
|
49d775c61f |
feat(volumes): add read-only volume browser (#926)
* feat(volumes): add read-only volume browser Adds a browser for the contents of any Docker named volume. Click the folder icon on a volume row (admin only) to open a sheet with a directory tree on the left and a file viewer on the right. Backend ------- New VolumeBrowserService spawns a one-shot Alpine 3.20 helper container with the target volume mounted read-only at /v. The container runs as nobody (65534:65534) with a read-only rootfs, no network, all caps dropped, no-new-privileges, and capped at 64 PIDs and 128 MiB. The helper image is pulled on first use per node. Listing and stat use a portable busybox-compatible shell loop (find -printf is not available on Alpine). Reads use head -c with an explicit -- separator; the helper's working directory is /v so user paths are passed as ./<path> argv elements and never as flags. The container lifecycle is managed manually (create, attach, start, wait, remove) to avoid the AutoRemove race where dockerode sees a 404 on its post-exit container lookup. Path safety: relative paths are sanitized server-side, rejecting parent-escape segments, absolute paths, null bytes, and oversized input. Symlinks are listed but never followed on read. Files larger than 5 MB are truncated; binary content is detected via null-byte scan and returned base64-encoded. Non-zero helper exits map to 404, 403, or 500 by classifying stderr. Routes mounted at /api/volumes: - GET /:name/list?path= - GET /:name/stat?path= - GET /:name/read?path= All three require admin. The read endpoint always inserts an audit log row (success or failure) with the actual response status code, volume name, and relative path. Frontend -------- FileTree generalized to take a loadDir callback and a sourceKey instead of a hard-coded stackName. The single existing consumer (StackFileExplorer) was updated and its tests rewritten. The loader is read through a ref so re-creating the arrow on every parent render does not re-trigger the root fetch effect. New VolumeBrowserSheet renders the tree against the volume API, shows file content (hex view for binaries), and surfaces truncation. Rapid sheet open and reopen on different volumes is generation- checked to avoid stomping the visible result with a stale read. A persistent footnote reminds the user that file reads are recorded in the audit log, and the docs page warns about the typical contents of database volumes. Tests ----- 15 new vitest cases cover the pure helpers (path traversal, volume name validation, binary detection). The Docker-facing exec path is exercised by manual end-to-end via curl against a seeded volume. * fix(volumes): truncate long volume names in browser sheet header Wide volume names overlapped the close X. Reserve right padding on the header, set min-w-0 on the flex title, mark the icon and refresh button shrink-0, and truncate the name span. * fix(volumes): satisfy lint on volume browser additions prefer-const on sanitizeRelPath's local; drop unused FileTree entry arg from the file-select callback (variance lets the arrow take fewer params than the contract). |
||
|
|
898ef1a0e8 |
feat(ui): add Modal chrome primitives, migrate file dialogs (#896)
Introduces shared <Modal>, <ModalHeader>, <ModalBody>, <ModalFooter>, and <ModalDestructiveHeader> primitives that wrap shadcn Dialog and encapsulate the canonical modal chrome: cyan rail at the left edge, mono uppercase kicker, italic serif title, footer hint, standardized button order (secondary outline, primary cyan / destructive). Migrates NewFolderDialog and DeleteFileConfirm onto the new primitives as the first proof points. The destructive variant flips the rail and kicker color to the destructive token without changing the chrome recipe. |
||
|
|
1f8ce773ff |
feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists. |
||
|
|
f62716f557 |
refactor(design): align typography, colors, and card surfaces to DESIGN.md (#859)
* refactor(design): align surface tokens to DESIGN.md §2 * refactor(design): canonicalize tracked-mono kickers and display rungs * refactor(design): collapse to five-slot palette and align card surfaces |
||
|
|
b5d038f395 |
perf(frontend): lazy-load Monaco editor + diff editor (#824)
Monaco-editor and @monaco-editor/react were imported eagerly from
main.tsx so that the locally-bundled Monaco was registered with
@monaco-editor/react (CSP blocks the default CDN load). This pulled
the ~3 MB monaco chunk into every cold app start regardless of
whether a user ever opened the editor.
Move the Monaco setup into a new frontend/src/lib/monacoLoader.tsx
module that exports React.lazy-wrapped Editor and DiffEditor
components. The lazy factory awaits a one-shot setupMonaco() that
dynamic-imports monaco-editor, @monaco-editor/react, and the editor
worker, then calls loader.config({ monaco }) and sets
window.MonacoEnvironment before resolving the underlying component.
Concurrent first mounts share a single setup promise so the work
runs at most once per process. The three consumers (EditorLayout,
FileViewer, GitSourceDiffDialog) wrap their editor in <Suspense>
with a transparent fallback that preserves layout while the chunk
loads.
main.tsx loses three eager imports plus the MonacoEnvironment +
loader.config bootstrap. The vite.config.ts manualChunks group from
PR #823 was already prepared for this; the monaco chunk now loads
on demand instead of being bundled into the entry chunk.
|
||
|
|
801a098a5b |
feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer
Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.
* fix(files): reject bare dot segments in isValidRelativeStackPath
* feat(files): add safe stack-scoped file I/O methods to FileSystemService
Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.
Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.
Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.
* feat(files): frontend API wrappers and Monaco language helper
* fix(files): tighten stackFilesApi error handling and localOnly support
* fix(files): FileSystemService safety and correctness fixes
* feat(files): add file explorer API endpoints to stacks router
* feat(files): FileTree and FileTreeNode components
* fix(files): route security hardening and stream cleanup
* fix(files): FileTree accessibility, icon stroke, stale fetch guard
Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.
* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm
* fix(files): resolve code quality findings in file explorer components
- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing
* test(files): unit tests for binary detection, stack path safety, and file explorer routes
- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
rejects matrix) and FileSystemService stack methods against a real temp dir
(listStackDirectory sort and protection flags, readStackFile text/binary/
oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
file explorer endpoints; covers auth gating, Community-tier 403 gates,
input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths
* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar
* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change
* test(files): add missing test coverage for file explorer routes and service
* feat(files): add Files tab to EditorLayout with StackFileExplorer integration
* fix(files): add defensive activeTab guard to saveFile and discardChanges
* test(files): unit tests for FileTree expand/collapse and FileViewer render modes
Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.
* test(e2e): file explorer community and skipper+ flows
Covers the full file-explorer feature surface in two describe blocks:
Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.
Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.
Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.
* fix(e2e): improve test isolation and selector stability in stack-files spec
Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.
Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.
* docs(files): add stack file explorer documentation
Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.
* fix(docs): use canonical Skipper tier name in file explorer overview card
* fix(files): resolve lint errors blocking CI
Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.
* test(files): fix e2e seeding to work on community-tier CI
Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.
Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
|