Catalog: Glacier rehydration UX in the preview - #4921
Conversation
Replace the dead-end "Object Archived" message with a two-click rehydrate flow: a dialog lets the user pick a retrieval tier (Standard/Bulk/Expedited) and a 1-90 day duration, and the preview surface tracks restore state via the S3 HEAD `x-amz-restore` header. No active polling; manual "Check status" plus an optimistic in-progress hold after S3 accepts a 202. The HEAD-derived `archived` flag now means "effectively archived" — a restored-but-still-Glacier object loads its preview normally and re-enables the file toolbar. LIST-derived sites (directory listings, version popover, summary gallery) intentionally stay raw to avoid per-row HEAD storms; NOTE comments document the gap. The new `restoreObject` request currently goes via the AWS SDK with a TODO marker to migrate to GraphQL once a server-side mutation lands.
- Replace the tier radio group with a native-backed select; lay the tier select and duration input side by side, with the per-tier timing/cost hint moved into the select's helper text. - Shorten the duration label to "Duration (days)" and move the explanation into helper text. - Surface restore errors inline as an Alert (not just a toast) so the AccessDenied/IAM hint is visible without leaving the dialog. - Clarify the intro copy: rehydrating makes the archived object temporarily downloadable and never creates a new version or loses the object.
Thread an optional storageClass through the existing PreviewError.Archived payload (alongside restore) so RehydrateDialog can drop the Expedited retrieval option for DEEP_ARCHIVE objects, where it isn't supported. When storageClass is absent (embed/search surfaces don't always plumb it) the dialog falls back to showing all tiers — no regression. Type notes: - storageClass reuses the SDK's S3.StorageClass — we only consume and compare it, so the type's `| string` widening costs nothing and documents provenance. - GlacierTier keeps its own literal union (we produce these values and want narrowing, which S3.Tier's `| string` member can't provide); add a comment explaining the deliberate divergence.
Tighten the comments added for the rehydration feature without losing meaning, and drop a self-explanatory one on the error fallback.
- restore.ts: compose isEffectivelyArchived from isArchiveStorageClass + hasLiveRestoredCopy so the body reads for itself. - requestsUntyped.js: reuse the shared isEffectivelyArchived instead of a duplicated inline archive/restore check and local helper. - RehydrateDialog: extract clampDays(n) (returns the clamped string), dropping the inline clamp + comment.
parseRestoreHeader / isEffectivelyArchived / RestoreStatus are generic S3 HEAD helpers with no React or Preview dependency, but they lived in components/Preview/loaders/, so requestsUntyped.js imported UP into a component — an inverted dependency. Move restore.ts (+ spec) next to object.ts in containers/Bucket/requests/; Preview now imports DOWN into requests, matching every other module.
The embed view showed the Rehydrate button but had no onReload/resetKey, so "Check status" never appeared and the view couldn't refresh after a submit. Add resetKey state + handleReload (mirroring File.jsx): thread resetKey into both getObjectExistence calls and previewOptions (useGate cache bust), and pass handleReload as onReload. Embed now refreshes like the main file view.
The four HEAD-throwing surfaces (File, embed/File, SearchResults, PackageTree)
each built Archived({ handle, restore, storageClass }) by hand — duplication
that already let embed diverge. Add a typed Preview.archivedError(handle, src)
helper (typed ArchivedSource inputs) so every call site stays consistent and
can't forget a field.
useGate is the fifth HEAD-throwing surface; route its Archived construction through Preview.archivedError like the other four, so no call site builds the payload by hand. Imported via ../archivedError (not the Preview index) to avoid the index -> load.jsx -> loaders cycle.
useGate's resetKey only ever busted gate's Data.use cache for a case that can't occur in the rehydration flow: the parent's getObjectExistence archived short-circuit always fires before Preview.load, so for an archived object the gated loader never mounts; on reload the parent refetches and the loader mounts fresh, re-running its HEAD. So resetKey threaded through useGate / ECharts / Markdown / previewOptions was dead — remove it, restoring gate to a pure function. The parent's getObjectExistence resetKey (which actually drives reload) stays. Breadcrumbs added at the archived short-circuits noting the loader-remount coupling reload depends on.
Replace the <Data> render-prop (and the awkward renderWithReload inner function that captured `fetch` and needed a no-use-before-define disable) with the useData hook, matching File.jsx and embed. handleReload is now the same one-liner resetKey bump the siblings use. Net change is small (+10/-20 ignoring the reindent from de-nesting AsyncResult.case).
Stop snapping the duration field on every keystroke (pasting 120 jumped to 90 mid-typing). handleDaysChange now stores the raw value; the existing daysValid check still gates submission — disabled button + "Enter a value between 1 and 90" helper text — so out-of-range input is caught before submit without fighting the user. Drop the unused clampDays helper. Tests reframed from clamp assertions to the validation matrix (empty, below min, above max, non-integer, in-range), which also guards against re-introducing clamp-on-change.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #4921 +/- ##
==========================================
+ Coverage 46.51% 46.70% +0.19%
==========================================
Files 832 837 +5
Lines 34094 34247 +153
Branches 5833 5891 +58
==========================================
+ Hits 15858 15995 +137
- Misses 16237 16253 +16
Partials 1999 1999
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
A RestoreAlreadyInProgress (409) closed the dialog with a toast but never
reloaded, leaving ArchivedMessage idle ("Rehydrate" button) while a restore
was actually running — re-clicking just produced another 409. Treat it like
a fresh 202: call onSubmitted(false) so the message flips to "Restore in
progress" and refetches. (greptile PR review finding.)
|
@greptileai the 409 "already in progress" finding is addressed in ed36473: the |
Map the new InvalidObjectState OperationError to ObjectNotArchivedError and surface it without logging to Sentry, instead of falling through to the generic 'Failed to start restore' path.
Adds max/glacier-rehydration-ux to deploy-catalog.yaml on.push.branches so CI builds a catalog image for the glacier.dev.quilttest.com test stack. Revert before merge.
The mock fixtures were missing the top-level __typename: 'Mutation' (and __typename: 'InputError'), which vitest ignores but the webpack/tsc build rejects. Add them so the catalog build type-checks.
Match the codebase pattern (BaseError → BucketError → specific errors used across containers/Bucket/errors.tsx): drop the manual setPrototypeOf and this.name = '…' boilerplate from the four restore-related errors, since BaseError already handles the prototype chain, captureStackTrace, and the dynamic name getter. Class names and instanceof semantics are unchanged, so RehydrateDialog catches and the GraphQL interpretRestoreResult dispatch work without modification.
The catalog's archived/restore detection (parseRestoreHeader, isEffectivelyArchived) reads the x-amz-restore HEAD response header to distinguish ongoing/expired restores from cold-archived objects. The s3-proxy was hard-coding the Access-Control-Expose-Headers list (and stripping S3's own) without including x-amz-restore, so browsers received the header but JS couldn't read it — rehydrated objects stayed showing 'Object Archived' instead of flipping to 'Restore in progress'. Add the header to the expose list.
getArchiveState returned the full parsed RestoreStatus (ongoing + expiresAt), but the only thing any caller ever read was ongoing; expiresAt was consumed solely inside hasLiveRestoredCopy to derive `archived`. Return a `restoring` boolean instead and keep RestoreStatus (and expiresAt) module-private. ArchiveInfo and the Exists payload now carry `restoring` rather than a RestoreStatus object, so nothing unrendered is threaded to the UI. Addresses nl0's review comment on PR #4921.
Two more ObjectAttrs consumers in PackageTree's FileDisplay still
destructured/passed `restore`; rename them to `restoring`. Also fix the
PreviewError.Archived doc comment in types.js to match the ArchiveInfo
shape ({ storageClass, restoring }).
|
Thanks @fiskus — verified the four review items, all resolved cleanly:
A few follow-ups before this is good to go:
Otherwise this looks good. ✅ |
The catalog file page reads an object's Glacier / Deep Archive restore state from the x-amz-restore HEAD response header; browsers only surface that header cross-origin when it's listed in Access-Control-Expose-Headers. Split out of #4921 (catalog Glacier rehydration UX) so this s3-proxy image change is independently reviewable and deployable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Re: the s3-proxy split (point 2 above) — done, I pulled it into #4954 (branched off |
getObjectExistence now yields a `restoring` flag, but ensureObjectIsPresent returns a plain S3 handle to summarize/README consumers that never read it. Drop it in the destructure alongside deleted/archived so the handle keeps its pre-existing shape.
|
Thanks — all four addressed:
|
|
@greptileai please re-review — the |
nl0
left a comment
There was a problem hiding this comment.
Approving — all review feedback is addressed and verified against 506fbbf9.
- Q1 Expedited fail-safe (offered only when class resolves to
GLACIER), Q2onSubmittedcollapsed to no-arg, Q3RestoreResultnamed at source +RestoreStatus/expiresAtconfined toglacier.ts— all confirmed. - Q4
ensureObjectIsPresentnow destructuresrestoringout, so the returned handle is the deliberate object location (fixed, not wontfixed). - s3-proxy
nginx.confsplit out to the now-merged #4953 → this PR is pure catalog scope; retitledCatalog:; description refreshed.
The IAM-as-sole-authority model is clean, ui.actions.restore is pattern-conformant across every BucketPreferences layer, no per-row HEAD storm (archive state rides the existing LIST via OptionalObjectAttributes), and the mutation union is handled exhaustively via interpretResult. 🚢
Restore is billable and mutates objects, so hide the CTA from anonymous users (mirrors the download/zip gate in FileView). Covered by two new anon-gate tests.
Add the feature branch to deploy-catalog's push trigger so its catalog image is built and pushed to ECR for pre-merge review. Revert before merging to master.
This reverts commit a2fe273.
|
@greptileai re-review please — added an authentication gate on the Rehydrate button (hidden from anonymous users, mirroring the download/zip gate in |
nl0
left a comment
There was a problem hiding this comment.
Re-approving after the stale-dismiss — the only PR-owned change since my last approval is ae639acb (gate Rehydrate button on authentication), which I reviewed: canRestore = authenticated && ui.actions.restore using the existing authenticated selector (mirrors the download/zip gate), with tests for the anon-hidden and anon-restoring-status cases. That cleanly resolves E1 catalog-side — anon never reaches the mutation. Everything else in the delta is the master merge (incl. the now-merged #4953). 🚢
|
Tiny description nit (not code): the s3-proxy bullet in the PR body points at #4954, which is my closed duplicate — the |
Summary
Replaces the dead-end "Object Archived — Preview not available" message with a rehydration flow for Glacier (
GLACIER) / Deep Archive (DEEP_ARCHIVE) objects.x-amz-restoreHEAD header. "Archived" now means effectively archived — a restored object previews normally and re-enables the toolbar.RehydrateDialog: pick a tier (Standard / Bulk / Expedited) and duration (1–90 days, validated). Expedited is offered only when the storage class resolves toGLACIER(hidden forDEEP_ARCHIVEand for unknown / HEAD-less classes, which fall back to Standard / Bulk). Errors show inline.ArchivedMessage: archived → restoring → restored. No background polling — while restoring it asks the user to refresh the page to check progress; the state is held optimistically after a202.ui.actions.restoreBucketPreference (default on) controls whether the Rehydrate button is shown, mirroring the otherui.actions.*toggles. The button is also hidden from unauthenticated users — restore is billable and mutates objects, so it's authenticated-only, like download/zip. Hiding it is UI-only — it neither grants nor removes access; the user's IAM role does.x-amz-restoreCORS expose-header (s3-proxy/nginx.conf) the browser HEAD needs to read restore state was already merged in s3-proxy: expose x-amz-restore header via CORS #4953 — s3-proxy is a separately built/deployed component, so this PR stays pure catalog scope.Implementation notes
restoreObjectGraphQL mutation under the user's role credentials, replacing the old directs3.restoreObjectSDK call. Per-user IAM is preserved. Typed errors map to UI affordances viainterpretResult:RestoreAlreadyInProgress→ in-progress flip,RestoreAccessDenied→ inline "ask your admin to enable rehydration" IAM hint,InvalidObjectState→ calm "not archived / already restored" message,GlacierExpeditedUnavailable→ suggest Standard/Bulk,BucketNotFound/ObjectNotFound→ calm "no longer exists" copy.RestoreStatus(requested viaOptionalObjectAttributes), so a restored object reads as not archived there too — at no extra request cost, since the status rides along in the existing LIST call. The file-detail page additionally reads the livex-amz-restoreHEAD header.Test plan
npm run lint,tsc --noEmit,npm run test:only(specs:glacier,restoreObject,RehydrateDialog,ArchivedMessage,BucketPreferences)GLACIER/DEEP_ARCHIVEobject, confirm Rehydrate → restoring → restoredui.actions.restore: false, shown by defaults3:RestoreObjectGreptile Summary
This PR introduces a full Glacier/Deep Archive rehydration UX in the Quilt catalog preview — replacing the dead-end "Object Archived" message with an interactive restore flow backed by a new
restoreObjectGraphQL mutation, optimistic in-progress state, and per-bucketui.actions.restorepreference.ArchivedMessage+RehydrateDialog: New components drive the archived → restoring → (reload to confirm) UX; rehydration is gated on both authentication and the per-bucketui.actions.restorepreference. TheRestoreAlreadyInProgresscase (previously flagged as a dead end) now correctly flipsoptimisticRestoringvia{ _tag: 'close', flip: true }.glacier.ts: Centralised helper parsesx-amz-restoreheaders andRestoreStatusLIST elements into a unified{ restoring, archived }shape; all five object-access paths (listing, file detail, package tree, embed, search results) and the preview gate now use it.restoreObjectmutation added to the shared GraphQL schema;ui.actions.restoretoggle wired throughBucketPreferences, the config editor, and JSON schema.Confidence Score: 5/5
Safe to merge — the rehydration flow is well-contained, all object-access paths are consistently updated, and the previously flagged RestoreAlreadyInProgress dead-end is now correctly handled.
All five object-access paths uniformly delegate to
getArchiveState, which is thoroughly unit-tested. TheRestoreAlreadyInProgresspath now correctly flipsoptimisticRestoringvia{ _tag: 'close', flip: true }. Auth and preference gates are independently tested, including the new anon-hidden case. No regressions detected in the existing preview or listing logic.No files require special attention. The CORS
expose-headerdependency (#4954) is acknowledged and ships separately; until then,x-amz-restorewill be absent from HEAD responses but the feature degrades gracefully.Important Files Changed
x-amz-restorestring and LISTRestoreStatusobject; edge cases (malformed header, missing expiry, expired copy) handled correctly and well-covered by specs.RestoreAlreadyInProgresspath correctly flipsoptimisticRestoringviaonSubmitted(); optimistic hold clears on object navigation via effect.interpretResultcleanly separates pure outcome mapping from imperative submit; Expedited gated onstorageClass === 'GLACIER'; all OperationError variants mapped to friendly messages with correct flip/close semantics.ArchivedMessagerender-prop pattern; Rehydrate action gated on!noDl(consistent with download/toolbar gating); passesarchiveinfo through correctly.getObjectExistencenow callsgetArchiveStateon the HEAD response;archivedfield carries `StorageClassObjectAttrsinterface updated to `archived: StorageClassOptionalObjectAttributes: ['RestoreStatus']tolistObjectsV2so restored copies surface as non-archived in listings; delegates archived calculation togetArchiveState.GlacierRestoreTierenum,RestoreObjectSuccesstype,RestoreObjectResultunion, andrestoreObjectmutation with correct nullableversionand requiredtier/daysargs.restoretoActionPreferencestype and defaults it totrue, consistent with the otherui.actions.*toggles.restoreObjectGraphQL mutation; mapsRetrievalTierto the schema enum; returns raw mutation union for the caller to branch on.Sequence Diagram
sequenceDiagram participant User participant ArchivedMessage participant RehydrateDialog participant GraphQL as restoreObject mutation participant S3 User->>ArchivedMessage: views archived object Note over ArchivedMessage: reads x-amz-restore header ArchivedMessage->>User: Object Archived + Rehydrate btn User->>ArchivedMessage: click Rehydrate ArchivedMessage->>RehydrateDialog: open(handle, storageClass) User->>RehydrateDialog: pick tier + days, submit RehydrateDialog->>GraphQL: restoreObject(bucket, key, version, tier, days) GraphQL->>S3: s3.restoreObject under user IAM role S3-->>GraphQL: 202 Accepted GraphQL-->>RehydrateDialog: RestoreObjectSuccess(alreadyRestored:false) RehydrateDialog->>ArchivedMessage: onSubmitted() flip:true RehydrateDialog->>RehydrateDialog: onClose() ArchivedMessage->>User: Restore in progress (optimistic) Note over User,S3: Hours later - user reloads page User->>ArchivedMessage: page reload ArchivedMessage->>S3: HEAD x-amz-restore S3-->>ArchivedMessage: object available ArchivedMessage->>User: Preview loads normallyReviews (9): Last reviewed commit: "Revert "TEMP: deploy catalog to ECR from..." | Re-trigger Greptile