Skip to content

web/admin: Objects tab on S3Detail (Phase 5) - #816

Merged
bootjp merged 12 commits into
mainfrom
feat/admin-spa-s3-objects
May 23, 2026
Merged

web/admin: Objects tab on S3Detail (Phase 5)#816
bootjp merged 12 commits into
mainfrom
feat/admin-spa-s3-objects

Conversation

@bootjp

Copy link
Copy Markdown
Owner

Summary

Phase 5 of docs/design/2026_05_22_proposed_admin_data_browser.md — adds the Objects tab to the S3 bucket detail page so operators can list / download / upload / delete objects through the admin SPA. Consumes the Phase-3b HTTP surface from PR #814.

Stacked on

This branch is built on feat/admin-http-s3-objects (PR #814). Merge order: #814 → this PR. CI compiles cleanly here because the Phase-3b server endpoints live in the parent branch.

Capabilities

RoleAllowed
read_onlylist + download
fullupload + delete (plus everything above)

UI

  • New "Objects" card on S3Detail with a paginated table (page size 100, mirroring the server default).
  • Breadcrumb navigation — clicking a path segment drops back to that prefix; "(root)" returns to the bucket root.
  • Folders surfaced via the server's CommonPrefixes (delimiter fixed to /). Folder rows render with a 📁 prefix and clicking drills into them.
  • Object detail modal — size / content-type / ETag / last-modified / storage-class + Download (anchor tag, download attr) + Delete (full role).
  • Upload — hidden <input type="file"> triggered by an Upload button. File uploads to {prefix}/{file.name} with its native Content-Type.
  • Delete — two-stage confirm (button row collapses to "Delete this object?").

API client

web/admin/src/api/client.ts grows:

  • AdminObject / AdminObjectListing / AdminListObjectsOptions types.
  • listObjects / deleteObject (JSON via apiFetch).
  • putObject / downloadObjectURL (bypass apiFetch because they carry raw bytes). putObject does a manual fetch() so the body can be a Blob; CSRF header still attached. downloadObjectURL returns the URL for an <a download> link so the browser streams the response and honours the server's Content-Disposition header.
  • base64UrlEncodeBytes: TextEncoder → btoa → url-safe alphabet, padding stripped.

Server-side rules honoured

  • 100 MiB upload cap is enforced server-side at both the http.MaxBytesReader and the adapter ErrAdminUploadTooLarge sentinel layers — both surface as 413.
  • Security headers on download (nosniff, sandbox CSP, attachment Content-Disposition, no-store) come from the server; the SPA does not override them.
  • Folder navigation issues a fresh listObjects under the new prefix rather than client-side filtering — keeps the page bounded at 100 entries regardless of bucket size.

Self-review (5 passes)

  1. Data loss — No SPA-side write path bypasses the server. The 100 MiB cap is enforced at two server-side layers.
  2. Concurrency — Modal busy blocks dismissal during save. Upload button disabled while in flight.
  3. Performance — Pagination bounded. Cursor stack lets Refresh reload the current page without restarting. Folder navigation is a fresh server call, not client filtering.
  4. Consistency — Prefix change resets to page 0. Refresh after upload reloads the current page so the operator sees their own write.
  5. Test coverage — Manual dev-server exercise only. Vitest harness for the SPA does not yet exist.

Test plan

Phase plan

Wires the Phase-3b /admin/api/v1/s3/buckets/{name}/objects HTTP
surface into the SPA's bucket-detail page. Driven by §4.x of
docs/design/2026_05_22_proposed_admin_data_browser.md.
Capabilities (gated on session role):
read_only — list + download
full — additionally upload + delete
UI:
- New "Objects" card on S3Detail with a paginated table (page size
100, mirroring the server's adminListObjectsDefaultMaxKeys).
- Breadcrumb navigation: clicking a path segment drops back to that
prefix. Clicking "(root)" returns to the bucket root.
- Folders surfaced via the server's CommonPrefixes (delimiter fixed
to "/"). Folder rows render with a 📁 prefix and clicking drills
into the prefix.
- Object rows open a detail modal showing size / content-type / ETag
/ last-modified / storage-class plus Download and Delete buttons.
- Upload: hidden <input type="file"> triggered by an "Upload" button.
The picked file uploads to `{prefix}/{file.name}` with its native
Content-Type. The 100 MiB server cap surfaces as a 413 from the
bridge.
- Delete uses a two-stage confirm (button row collapses to "Delete
this object?") so a click cannot accidentally destroy bytes.
API client (web/admin/src/api/client.ts):
- AdminObject / AdminObjectListing / AdminListObjectsOptions type
definitions mirror the Go handler.
- listObjects (JSON) / deleteObject (JSON) reuse the existing
apiFetch path with the CSRF header.
- putObject / downloadObjectURL bypass apiFetch because they carry
raw bytes (application/octet-stream):
* putObject does a manual fetch() so the body can be a Blob; the
CSRF header is still attached.
* downloadObjectURL returns the URL for a plain <a download> link
so the browser streams the response and honours the server's
Content-Disposition: attachment header.
- base64UrlEncodeBytes builds the {key-b64url} segment via
TextEncoder → btoa → url-safe alphabet, padding stripped.
Self-review (5 passes):
1. Data loss — no SPA-side write path bypasses the server's
validation; the 100 MiB cap is enforced at both the
http.MaxBytesReader and the adapter ErrAdminUploadTooLarge
sentinel layers.
2. Concurrency — Modal busy gates Esc / backdrop dismissal so a
half-completed save cannot be orphaned; upload busy gates the
Upload button.
3. Performance — List paginates at 100; cursor stack lets Refresh
reload the current page without restarting the scan. Folder
navigation runs a fresh listObjects under the new prefix
rather than client-side filtering.
4. Consistency — Prefix change resets to page 0. Refresh after
upload reloads the current page so the operator sees their own
write immediately.
5. Test coverage — Manual exercise of dev server only. Vitest
harness for the SPA does not yet exist; encoding helpers will
get property tests when that lands.
npm run lint (tsc -b --noEmit) — passes
npm run build (tsc -b && vite build) — passes (bundle output is
.gitignored; internal/admin/dist/index.html placeholder
unchanged in this commit)
@coderabbitai

coderabbitaiBot commented May 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@bootjp, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 1 review/hour. Refill in 38 minutes and 21 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7f10412e-7c9b-49ea-ad99-1ce1053d31f8

📥 Commits

Reviewing files that changed from the base of the PR and between d6f5d42 and 4f90f89.

📒 Files selected for processing (3)
  • web/admin/src/api/client.ts
  • web/admin/src/components/S3ObjectsTab.tsx
  • web/admin/src/pages/S3Detail.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/admin-spa-s3-objects

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Phase 5: S3 Objects tab. Stacked on PR #814 (Phase 3b).

  • Paginated table (100), breadcrumb prefix navigation, folder rows
  • Object detail modal: metadata + Download (anchor + download attr) + Delete (full role)
  • Upload: hidden file input + manual fetch() with Content-Type + CSRF
  • API client: AdminObject types + 4 methods + base64UrlEncodeBytes helper

npm run lint / npm run build pass. Backend tests unchanged.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 3m 13s —— View job


Code Review: Phase 5 — S3 Objects Tab

Overall the implementation is clean. The breadcrumb prefix navigation, two-stage delete confirm, and upload-then-refresh flow are solid. A few issues worth addressing before merge.


Medium — Race condition in loadPage (no AbortController)

S3ObjectsTab.tsx:43-66

loadPage never receives an AbortSignal, so two concurrent loads (rapid breadcrumb clicks, prefix changes while a slow response is in-flight) race on setPage. The slower response can land after the newer one and overwrite it with stale data.

The useEffect should cancel the previous request on cleanup:

useEffect(()=>{constac=newAbortController();setCursorStack([]);voidloadPage(undefined,prefix,ac.signal);return()=>ac.abort();// eslint-disable-next-line react-hooks/exhaustive-deps},[bucket,prefix]);

Thread the signal through loadPage(cursor, p, signal?) into api.listObjects(bucket, opts, signal) — the third arg is already wired in the client.


Medium — putObject throws on any non-204 success

client.ts:413-424

if(res.status===204)return;// falls through: parses JSON and always throws

The Go handler always returns 204 today (s3_objects_handler.go:232), but the guard should be if (res.ok) return;. If the server ever returns 200 OK with a body, every upload would silently fail on the client.


Low — Objects tab gated on describeBucket success

S3Detail.tsx:112

{detail.data&&<S3ObjectsTabbucket={name}/>}

If describeBucket returns a non-404 error (503 during a Raft leader flip, for example), the Objects tab is hidden even though list/download would succeed. Rendering <S3ObjectsTab> independently means it can show its own error state instead of silently disappearing.


Low — Session expiry turns download into a garbage file

S3ObjectsTab.tsx:357-365

An <a download> triggers direct browser navigation. If admin_session expires before the click, the browser downloads the 401/403 JSON error response as a file with a base64-encoded filename. putObject already uses manual fetch() to check status before proceeding; the same pattern could be applied here to detect auth failures gracefully.


Nit — Breadcrumb key={idx}

S3ObjectsTab.tsx:282key={target} (the reconstructed path string) would be stable and semantically meaningful instead of the array index.


Nit — Disabled breadcrumb segment has no visual cue

S3ObjectsTab.tsx:285-291 — The current (last) segment is disabled but styled identically to clickable ancestors. Dropping the underline or adding font-semibold on the disabled item would help operators see where they are.


Confirmed correct

  • base64UrlEncodeBytes: UTF-8 → TextEncoder bytes → latin1 string → btoa → url-safe substitution → strip padding. Correct pattern; handles non-ASCII keys.
  • CSRF attached to PUT and DELETE; GET and the download anchor correctly omit it.
  • cursorStack logic: the last element is always the token that loaded the current page, so Refresh correctly replays without rewinding to page 0. Prefix change resets the stack via the useEffect dep, preventing stale tokens across folder navigations.
  • Server normalizes nil → []AdminObject{} before serializing (s3_objects_handler.go:131), so page?.objects.map() without optional chaining on .objects is safe.
  • No dangerouslySetInnerHTML; React escapes all object keys and metadata in JSX text positions.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:388dd46c8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +62 to +65
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel stale object-list requests on prefix/bucket changes

This effect starts a new listObjects call whenever bucket or prefix changes, but it does not cancel or ignore prior in-flight requests. If an operator navigates folders quickly (or switches buckets), an older response can resolve last and overwrite state with stale rows for the previous prefix, so the table and object actions no longer match the current breadcrumb context.

Useful? React with 👍 / 👎.

Comment on lines +71 to +72
setCursorStack((s) => [...s, next]);
void loadPage(next, prefix);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance pagination cursor only after fetch succeeds

onNextPage appends next_continuation_token to cursorStack before the page fetch completes. On a transient API failure, the UI still shows the old page but internal cursor state has already moved forward; subsequent Refresh/post-mutation reloads use the wrong token and can jump to a different page unexpectedly.

Useful? React with 👍 / 👎.

@gemini-code-assistgemini-code-assistBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive S3 object management interface, allowing users to list, upload, download, and delete objects within buckets. Key additions include new API client methods, TypeScript interfaces for S3 metadata, and the S3ObjectsTab component for the admin dashboard. The review feedback suggests enhancing the reliability of the UI by implementing request cancellation using AbortController to prevent race conditions during navigation and broadening the success criteria for object uploads to support standard S3-compatible responses.

Comment on lines +62 to +66
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucket, prefix]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The useEffect hook triggers a page load whenever the bucket or prefix changes, but it lacks a cleanup mechanism to abort in-flight requests. This can lead to race conditions where a slower response from a previous prefix/bucket overwrites the state of the current one. Consider using an AbortController to cancel stale requests.

Suggested change
useEffect(()=>{
setCursorStack([]);
voidloadPage(undefined,prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps
},[bucket,prefix]);
useEffect(()=>{
constcontroller=newAbortController();
setCursorStack([]);
voidloadPage(undefined,prefix,controller.signal);
return()=>controller.abort();
},[bucket,prefix]);

// without scanning from the start.
const [cursorStack, setCursorStack] = useState<string[]>([]);

const loadPage = async (cursor: string | undefined, p: string) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The loadPage function should accept an AbortSignal to allow the caller (like useEffect) to cancel the request if the component unmounts or the parameters change.

Suggested change
constloadPage=async(cursor: string|undefined,p: string)=>{
constloadPage=async(cursor: string|undefined,p: string,signal?: AbortSignal)=>{

Comment on lines +47 to +52
const result = await api.listObjects(bucket, {
prefix: p,
delimiter: DELIMITER,
continuation_token: cursor,
max_keys: PAGE_SIZE,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the signal to the api.listObjects call to ensure the network request is actually aborted when the controller is triggered.

Suggested change
constresult=awaitapi.listObjects(bucket,{
prefix: p,
delimiter: DELIMITER,
continuation_token: cursor,
max_keys: PAGE_SIZE,
});
constresult=awaitapi.listObjects(bucket,{
prefix: p,
delimiter: DELIMITER,
continuation_token: cursor,
max_keys: PAGE_SIZE,
},signal);

Comment threadweb/admin/src/api/client.ts Outdated
`${apiBase}/s3/buckets/${encodeURIComponent(bucket)}/objects/${base64UrlEncodeBytes(key)}`,
{ method: "PUT", body, headers, credentials: "same-origin" },
);
if (res.status === 204) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The putObject method only treats HTTP 204 as a success. While the current Go backend implementation returns 204, standard S3-compatible APIs may return 200 OK for successful PUT operations. It is safer to check res.ok or handle 200 explicitly to avoid unexpected ApiError throws when the body is empty but the status is 200.

Suggested change
if(res.status===204)return;
if(res.ok)return;

…x putObject (PR #816 r1)
Three reviews on PR #816 (1 Codex P1, 1 Codex P2, 1 Gemini high
echoing the P1, 1 Gemini medium).
1 Codex P1 / Gemini high - cancel stale list requests on
prefix/bucket changes. The previous loadPage had no
cancellation, so an older slower listObjects could resolve
last and overwrite state with stale rows for the previous
prefix. Follow-up download/delete actions on those rows
would then mutate the wrong resource - the bucket/key
identity comes from the rendered row.
Fix: listAbortRef tracks the in-flight controller. Every
loadPage() aborts the predecessor; a generation-token check
(listAbortRef !== ctrl) covers the race where a response
lands before abort() is processed. AbortError on the catch
path is suppressed so a rapid Next-page click sequence
doesn't show aborted mid-navigation. Unmount effect
aborts the last controller too.
2) Codex P2 - advance cursorStack only after fetch succeeds.
The old onNextPage pushed the new continuation token
before the fetch resolved. On a transient API failure the
UI still showed the old page while internal cursor state
had moved forward, so Refresh / post-mutation reloads used
the wrong token and jumped to an unrelated page.
Fix: loadPage now returns a success boolean. onNextPage
awaits the result and only pushes the new cursor when the
fetch resolved successfully. Refresh + post-mutation
reloads stay aligned with the actual rendered page.
3) Gemini medium - putObject treated only 204 as success.
Now treats any 2xx as success via res.ok. The current Go
handler returns 204 but a future change that returns 200
with the metadata body would otherwise misclassify as a
non-JSON error.
Self-review (5 passes):
Data loss: closes a write-to-wrong-bucket-or-key vector on
the follow-up delete/download path. Closes a cursor-drift
vector on the pagination state machine.
Concurrency: AbortController + generation-token covers both
abort-fired and abort-late races.
Performance: aborted requests waste bytes but the
alternative is operator-visible incorrectness.
Consistency: same pattern as the Phase-4 DynamoItemsTab r1
fix (PR #815 r1) so future audits find one shared shape.
Test coverage: no SPA test harness yet; manual exercise of
the dev server confirms rapid folder navigation behaves
correctly and a forced-fail Next-page leaves cursorStack
intact.
npm run lint (tsc) - passes
npm run build (vite) - passes (dist/index.html placeholder
unchanged in this commit)
EOF
)
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 1 addresses 4 findings:

  • Codex P1 / Gemini high — stale list races. listAbortRef cancels predecessor on every loadPage + generation-token check on resolved-value path.
  • Codex P2 — advance cursorStack only after fetch succeeds. loadPage now returns a success boolean; onNextPage awaits and pushes the cursor only on true.
  • Gemini mediumputObject now treats any 2xx as success (res.ok) instead of only 204.

Same race-condition shape as the Phase-4 DynamoItemsTab r1 fix on PR #815 r1. npm run lint / npm run build pass.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 34s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 1 Review)

  • Reading changed files
  • Analyzing Round 1 fixes
  • Checking for remaining issues
  • Posting review

Round 1 fixes — all three targets verified correct

1. Stale list races (listAbortRef + generation token) — ✅

The implementation is clean and covers both abort scenarios:

  • Every loadPage() call aborts the predecessor and creates a fresh AbortController (listAbortRef.current = ctrl).
  • The generation-token guard (listAbortRef.current !== ctrl) in both the success and catch paths handles the window between abort firing and the in-flight response resolving.
  • The finally guard (if (listAbortRef.current === ctrl)) prevents the settled controller from clearing loading while a newer request is in-flight.
  • The unmount-only useEffect (return () => listAbortRef.current?.abort()) covers the component-gone case.

One minor note: loadPage is recreated on every render and intentionally omitted from the useEffect dep array (suppressed via // eslint-disable-next-line). This is safe because loadPage only closes over bucket (which is in the dep array) and takes prefix/cursor as arguments rather than capturing them from state. If the function gains new state dependencies in the future, the suppressed lint rule won't catch it — useCallback would be the cleaner long-term fix, but it's not a bug today.

2. Cursor advance only on success — ✅

loadPage returning bool and onNextPage awaiting before pushing is correct. The "Next page" button being disabled={loading} means rapid double-clicks are blocked in practice, but the logic is sound even without that guard — the aborted first call returns false, so only the second invocation can push the cursor.

3. putObject treats any 2xx as success — ✅

res.ok is the right check. The error-body parse path below it (res.headers.get("content-type")) is still sound for non-2xx responses.


Remaining items from original review (not addressed in r1)

These were all Low / Nit priority in the original review, none block merge.

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data&&<S3ObjectsTabbucket={name}/>}

A 503 during a Raft leader flip still hides the tab silently. The detail error block on lines 71-73 already renders the error text in the metadata card, so operators will see something — but they lose access to list/download even though those calls would succeed. Acceptable as-is if the current priority is keeping the surface area small.

Low — Download anchor + session expiry (S3ObjectsTab.tsx:399-408)

An <a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. No change from r0; still a low-priority UX polish item.

Nit — Breadcrumb key={idx} still present (S3ObjectsTab.tsx:324)

{segments.map((seg,idx)=>{consttarget=segments.slice(0,idx+1).join("/")+"/";return<spankey={idx}...>

key={target} (the reconstructed path string) is stable and semantically meaningful. Easy one-liner fix this →.

Nit — Disabled current breadcrumb segment is visually identical to clickable ancestors (S3ObjectsTab.tsx:326-333) — no change from r0.


Summary

Round 1 is correct. All three targeted findings are properly fixed. The abort pattern (ref + generation token + unmount cleanup) is the same shape used in Phase-4 DynamoItemsTab, which is good for future auditability. The four remaining items are all low/nit and were already known before r1. Ready to merge after #814 lands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:927117bcbc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +96 to +99
useEffect(() => {
setCursorStack([]);
void loadPage(undefined, prefix);
// eslint-disable-next-line react-hooks/exhaustive-deps

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset prefix state before loading a different bucket

When bucket changes, this effect immediately calls loadPage(undefined, prefix) but keeps the previous bucket’s prefix in state, so navigating from bucket-a/photos/ to bucket-b can open bucket-b scoped to photos/ instead of root. In React Router, param changes typically reuse the same component instance, so this stale prefix is realistic and can hide root objects (or cause uploads/deletes to target an unintended subpath) until the user manually clicks (root).

Useful? React with 👍 / 👎.

…ath (PR #816 r2)
Codex P2 on PR #816. React Router param changes reuse the same
S3DetailPage instance, so when the operator navigates from
`/s3/bucket-a` (sitting at prefix "photos/") to
`/s3/bucket-b`, the prefix state stayed at "photos/". Result:
- bucket-b's list call ran against prefix="photos/" instead of
root, hiding root-level objects until the operator manually
clicked "(root)";
- subsequent upload would write to bucket-b/photos/{file}
instead of bucket-b/{file};
- subsequent delete would target the wrong subpath if the row
happened to render anything.
Fix: new useEffect with deps [bucket] dispatches setPrefix("")
on every bucket change. The existing [bucket, prefix] effect
re-fires on the next render with the fresh empty prefix; the
brief loadPage(undefined, oldPrefix) that runs from this
render's commit is cancelled by listAbortRef before any rows
land in state.
Tested manually: navigating /s3/foo → /s3/bar via the bucket
list now opens bar at root regardless of how deep the operator
had drilled in foo.
npm run lint — passes
npm run build — passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 2 addresses Codex P2 (bucket-change keeps stale prefix). New effect with deps [bucket] resets prefix to "" on every bucket change; AbortController cancels the brief loadPage that fires from the same commit before any rows land.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 8s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 2 Review)

  • Reading changed files
  • Analyzing Round 2 fix (bucket-change stale prefix)
  • Checking remaining items from prior rounds
  • Posting review

Round 2 fix — verified correct ✅

S3ObjectsTab.tsx:110-112 — new useEffect with deps [bucket]:

useEffect(()=>{setPrefix("");},[bucket]);

The execution sequence when bucket changes is exactly as described in the commit message:

  1. Render 1 — both the new [bucket] effect and the existing [bucket, prefix] effect fire (in declaration order). The [bucket] effect schedules setPrefix(""); the [bucket, prefix] effect calls loadPage(undefined, oldPrefix), setting listAbortRef.current = ctrl1 and loading = true.
  2. Render 2 — React processes the setPrefix("") update. prefix is now "", so [bucket, prefix] fires again. It calls loadPage(undefined, ""), which immediately does listAbortRef.current?.abort() (aborting ctrl1) and sets listAbortRef.current = ctrl2.
  3. The stale call (ctrl1) can't land in either the success or catch path — listAbortRef.current !== ctrl1 evaluates true in both guards → returns false without touching state.

The two-layer protection is correct: the AbortController makes a best-effort network cancel, and the generation-token check (listAbortRef.current !== ctrl) provides the fallback for any race between abort propagation and response resolution. Both are necessary and both are present.

One cosmetic note (not a bug): On Render 1, loadPage(undefined, oldPrefix) fires before Render 2 can abort it, so loading = true is set for a frame with the breadcrumb still at oldPrefix. Operators browsing quickly between buckets may see a brief flicker of the loading spinner under the previous prefix before Render 2 issues the correct call. This is imperceptible in practice and functionally harmless — not worth the added complexity of guarding it.


Remaining open items (unchanged from prior rounds — all Low/Nit, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx)

{detail.data&&<S3ObjectsTabbucket={name}/>}

A 503 during a Raft leader flip hides the tab silently. Unchanged from r1. The error text in the metadata card gives operators a signal, but they lose list/download even though those calls would succeed independently. Acceptable as-is.

Low — Download anchor + session expiry (S3ObjectsTab.tsx:418-426)

<a download> opened after session expiry delivers the 401 JSON as a file named after the base64-encoded key. Unchanged from r0. UX polish item only.

Nit — Breadcrumb key={idx} (S3ObjectsTab.tsx:343)

{segments.map((seg,idx)=>{consttarget=segments.slice(0,idx+1).join("/")+"/";return<spankey={idx}...>

key={target} is stable and semantically meaningful. One-liner fix this →.

Nit — Disabled current breadcrumb segment is visually identical to clickable ancestors (S3ObjectsTab.tsx:348)

No change from r0.


Summary

Round 2 is correct. The bucket-change stale-prefix race (Codex P2) is properly fixed via the two-effect pattern ([bucket] schedules setPrefix(""); the refire of [bucket, prefix] issues the correct network call and aborts the stale one via listAbortRef). All three PR-round fixes (abort races r1, cursor-advance-on-success r1, putObject res.ok r1, stale-prefix r2) are sound. Four low/nit items remain open and were known before r2. Ready to merge after #814 lands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:d40ac0acef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +86 to +87
setLoadError(err instanceof ApiError ? `${err.code}: ${err.message || err.code}` : String(err));
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear stale rows when object-list fetch fails

When loadPage hits a non-abort error, it sets loadError but leaves page untouched, so the previous bucket/prefix rows remain visible and clickable under the new context. After a route change (e.g., /s3/a to /s3/b) followed by a transient list failure, operators can still open stale rows and trigger download/delete against the current bucket with an old key, which can operate on the wrong object if that key exists in both buckets. Clearing page (and related cursor state) on error avoids cross-context actions from stale data.

Useful? React with 👍 / 👎.

Codex P1 on PR #816 r2. The previous loadPage error path set
loadError but left `page` populated with the prior bucket /
prefix's rows. After /s3/a -> /s3/b followed by a transient list
failure, bucket-a's rows stayed visible under the bucket-b
context — clicking Download or Delete on those rows then
operated against the wrong bucket with an old key, which can hit
an existing object if the key happens to collide.
Fix: on non-abort error, loadPage now calls setPage(null) and
setCursorStack([]). The table renders the empty/error state, no
stale rows remain clickable, and a subsequent Refresh cannot
reuse a continuation token that no longer addresses anything
under the new context. This is a fail-closed semantic — better
to surface 'no data' under an error banner than to silently
keep cross-context rows in play.
Caller audit (semantic-change rule): loadPage is private to
S3ObjectsTab; the five callers (init effect, onNextPage,
onRefresh, onFilePicked post-upload, onDelete post-delete) all
treat the return value as advisory and don't rely on `page`
staying populated through a failure. onNextPage already
short-circuits on ok=false; the others void-cast the result.
No external consumer.
Self-review (5 passes):
Data loss: closes a write-to-wrong-bucket-or-key vector on
the post-failure click path. No write-path semantics changed.
Concurrency: no shared state introduced beyond the existing
page / cursorStack setters.
Performance: one extra setState pair on the error path.
Consistency: same fail-closed-on-error shape the rest of the
SPA error surfaces follow (no half-rendered table).
Test coverage: manual exercise confirms a forced 500 from
listObjects collapses the rows and shows the error banner;
a subsequent Refresh on the new context loads the right
rows.
npm run lint - passes
npm run build - passes (dist/index.html placeholder unchanged
in this commit)
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 3 addresses Codex P1 (stale rows after fetch failure remain clickable under new bucket context).

loadPage now clears page to null and cursorStack to [] on a non-abort error. Fail-closed: better to show an empty table + error banner than to leave cross-context rows in play where a Download/Delete click would operate on the wrong bucket.

Caller audit: loadPage is private to S3ObjectsTab; the five call sites (init effect, onNextPage, onRefresh, post-upload reload, post-delete reload) all treat the return value as advisory and don't rely on page staying populated through a failure. No external consumer.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 18s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 3)

  • Read S3ObjectsTab.tsx to verify Round 3 fix
  • Verify error path clears page and cursorStack
  • Audit all 5 call sites for compatibility with fail-closed behavior
  • Check remaining open items from prior rounds
  • Post review

Round 3 fix — verified correct ✅

S3ObjectsTab.tsx:83-97 — two new lines in the non-abort catch path:

setPage(null);setCursorStack([]);

The placement is correct: both abort guards (listAbortRef.current !== ctrl, AbortError name check) fire first, so these state resets only run when the error is attributable to the current load — not a stale one that was superseded by a newer request. This mirrors the same generation-token discipline used in the success path.

Caller audit for the five call sites (all confirmed safe):

Call siteReturns-value used?Effect on error
Init/bucket+prefix effect (line 127)void — discardedsetCursorStack([]) already ran before loadPage; the loadPage error's own setCursorStack([]) is a harmless no-op duplicate. page → null + error banner.
onNextPage (line 140)await; pushes to cursorStack only on truepage → null, cursorStack → [] (resets to root), error banner. Next Refresh starts from page 0. Correct fail-closed — the token for the failed page may not be replayable.
onRefresh (line 148)void — discardedpage → null, cursorStack → [], error banner. top on the next call becomes undefined → page 0.
Post-upload reload (line 180)void — discardedUpload succeeded; reload fails. page → null, error banner. Operator sees upload succeeded but list is unavailable — preferable to showing stale rows with the uploaded file absent.
Post-delete reload (line 215)void — discardedDelete succeeded (setDetail(null) already fired, closing modal); reload fails. Same: page → null, error banner.

One subtlety worth noting: onNextPage on error resets cursorStack → [], which means a subsequent Refresh will start from page 0 rather than retrying the failed next-page token. This is the right choice — the continuation token is opaque and may correspond to a transient server state; retrying against page 0 is deterministic.


Confirmed still correct from prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2 — bucket-change [bucket] effect resets prefix to "" before the [bucket, prefix] effect fires; listAbortRef cancels the brief stale call.

Remaining open items (unchanged from r2 — all Low/Nit, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data&&<S3ObjectsTabbucket={name}/>}

A 503 during a Raft leader flip still hides the Objects tab silently. The metadata-card error text gives operators some signal, but list/download would succeed independently. Unchanged from r1.

Low — Download anchor + session expiry (S3ObjectsTab.tsx:428-436)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Unchanged from r0. UX polish only.

Nit — Breadcrumb key={idx} (S3ObjectsTab.tsx:353)

{segments.map((seg,idx)=>{consttarget=segments.slice(0,idx+1).join("/")+"/";return<spankey={idx}...>

key={target} is stable and semantically meaningful. One-liner fix this →.

Nit — Disabled current breadcrumb segment visually identical to clickable ancestors (S3ObjectsTab.tsx:355-365) — unchanged from r0.

Low (pre-existing, not introduced by r3) — Detail modal doesn't reset on bucket change

If the operator has a detail modal open for an object in bucket-a and then navigates to bucket-b (React Router reuses the component instance, only the bucket prop changes), the detail state persists — the modal would remain open referencing a stale key. Clicking Delete would then call api.deleteObject(bucketB, staleBucketAKey). The r3 fix addresses the table rows; the modal is a separate state slice and would need a useEffect(() => { setDetail(null); }, [bucket]) to close it on bucket change. This is a pre-existing gap and not what r3 set out to fix; noting it for completeness.


Summary

Round 3 is correct and minimal. The two-line addition (setPage(null); setCursorStack([])) in the non-abort catch path is correctly positioned after both abort guards and matches the generation-token discipline established in r1. All five call sites are compatible with fail-closed behavior. The four prior-round fixes (abort races, cursor-advance, putObject res.ok, bucket stale-prefix) remain sound. Ready to merge after #814 lands.

…y (PR #816 r4)
Claude review on PR #816 r3 caught one remaining cross-bucket
state leak and one breadcrumb nit.
1) Detail modal didn't reset on bucket change. Same React Router
reuse-the-same-component-instance class that r2 fixed for
prefix: if the operator had an object detail modal open for an
object in bucket-a and then navigated to /s3/bucket-b, the
modal stayed open referencing the stale bucket-a key. Clicking
Delete then called api.deleteObject(bucketB, staleBucketAKey)
- which can hit an existing object if the key happens to
collide.
Fix: the existing useEffect([bucket]) that already reset prefix
now also clears detail / confirmDelete / detailError. Same
timing rationale - the listAbortRef + the [bucket, prefix]
effect's loadPage call together ensure the post-render state is
the bucket-b root view with no open modal.
2) Breadcrumb React key was the loop index; swapped to the segment
target ("foo/bar/" form). idx is a stable React key when the
list neither shrinks nor reorders, but the breadcrumb DOES
shrink when the operator drops to a shallower depth - keying
on target lets React reuse the right span across renders
without churning unrelated DOM.
Caller audit (semantic-change rule):
- No semantic changes to any function. setDetail / setConfirmDelete
/ setDetailError are existing useState setters; adding them to
the bucket-change effect is a new call site, not a contract
change. The signatures, return values, and error vocabulary of
every function in S3ObjectsTab are unchanged.
Self-review (5 passes):
Data loss - closes the modal-bound write-to-wrong-bucket vector
(delete + future edit). Same fail-closed shape as the r2
prefix reset.
Concurrency - the effect adds three setState calls; React
batches them into one re-render.
Performance - negligible (three setState calls on a relatively
rare bucket-change event).
Consistency - the modal close mirrors the existing prefix /
cursorStack resets so future audits find one shared pattern.
Test coverage - manual exercise confirms the modal closes on
/s3/a -> /s3/b navigation, including the case where the
modal was in the confirm-delete state.
npm run lint - passes
npm run build - passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 4 addresses the two items from your r3 review:

  • Low (real): detail modal didn't reset on bucket change. Same React Router cross-context class as the r2 prefix fix. The existing useEffect([bucket]) now also clears detail / confirmDelete / detailError so a stale-bucket modal can no longer fire api.deleteObject(bucketB, staleBucketAKey).
  • Nit: breadcrumb key={idx}key={target}. Idx is unstable when the breadcrumb shrinks; the segment target is stable.

The other r3-noted Low items (Objects tab gated on detail.data, download anchor + session expiry) are pre-existing patterns shared with the rest of the SPA — out of scope for this PR.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

(Re-pinging: the previous review run errored with 'Failed to compute SHA' on S3ObjectsTab.tsx — appears to be a CI git infra issue, not a code one. The r4 commit 290e9455 is intact on the branch.)

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 7s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 4)

  • Read changed files (r4 commit 290e9455)
  • Verify Round 4 fixes
  • Check remaining items from prior rounds
  • Post review

Round 4 fixes — both verified correct ✅

1. Detail modal reset on bucket change — ✅

S3ObjectsTab.tsx:128-133

useEffect(()=>{setPrefix("");setDetail(null);setConfirmDelete(false);setDetailError(null);},[bucket]);

The fix is correctly placed and covers all three modal-related state slices. The timing argument from r2 still holds: the [bucket] effect fires before [bucket, prefix], so React batches all four setState calls (prefix + detail + confirmDelete + detailError) into a single re-render. By the time [bucket, prefix] fires and issues the new loadPage call, the modal is already closed with no stale state. The listAbortRef pattern provides network-level cancellation of the brief stale loadPage(undefined, oldPrefix) that fires in the interim.

The three new additions specifically close the cross-bucket delete vector identified in r3: if an operator had a detail modal open for bucket-a/some-key and navigated to /s3/bucket-b, clicking Delete could previously call api.deleteObject(bucketB, staleBucketAKeyName). All three modal slices (detail, confirmDelete, detailError) need resetting — and all three are now cleared.

2. Breadcrumb key={target} — ✅

S3ObjectsTab.tsx:369

<spankey={target}className="flex items-center gap-1">

target is the reconstructed path string ("photos/", "photos/2024/", etc.), stable across renders and unique within a given breadcrumb. When the operator drops to a shallower depth (clicking a parent segment), React now reconciles by the path string rather than the array index, so it removes the correct trailing spans rather than reusing their DOM nodes for non-equivalent positions. The fix is the minimal correct one — no surrounding changes.


Confirmed correct from prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2[bucket] effect resets prefix to "" before [bucket, prefix] fires; listAbortRef cancels the stale call.
  • r3setPage(null); setCursorStack([]) in the non-abort catch path, positioned correctly after both abort guards; fail-closed across all five call sites.

Remaining items (unchanged from r3 — all Low/pre-existing, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data&&<S3ObjectsTabbucket={name}/>}

A 503 during a Raft leader flip hides the Objects tab silently even though listObjects/downloadObject would succeed independently. The metadata-card error text gives operators some signal. Pre-existing pattern; not introduced by any of the r1-r4 changes.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

An <a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 4 is correct. Both targeted items (detail modal cross-bucket leak + breadcrumb key stability) are properly fixed with minimal, well-placed changes. The four-round fix progression (abort races r1, cursor-advance r1, putObject res.ok r1, stale-prefix r2, fail-closed on error r3, stale-modal r4, breadcrumb key r4) is complete. Two low-priority pre-existing items remain open and were known before r4. Ready to merge after #814 lands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:290e945565

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

await api.deleteObject(bucket, detail.key);
setDetail(null);
setConfirmDelete(false);
void loadPage(cursorStack[cursorStack.length - 1], prefix);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset cursor when delete leaves current page empty

After a successful delete this reload uses the current continuation token, which can strand the UI on an empty tail page even when earlier pages still contain objects (e.g., 101 objects, delete the only item on page 2). In that case objectCount becomes 0, the tab shows the "bucket is empty" state, and there is no previous-page control to recover within the tab because refresh reuses the same token. This should fall back to an earlier cursor (or page 1) when the post-delete page comes back empty.

Useful? React with 👍 / 👎.

…r5)
Codex P2 on PR #816 r4. The onDelete success path reloaded with
the same continuation token, which can strand the UI on an empty
tail page even when earlier pages still have content. Example:
101 objects, page size 100, operator drills to page 2 (1 object),
deletes it. The reload uses the same cursor → scans from after
the deleted item → returns empty → tab shows 'bucket is empty'
even though page 1 still has 100 objects.
Fix: before issuing the reload, check whether the current page
would become empty after the local delete
(objects.length <= 1 && common_prefixes.length == 0). When true
AND the operator is past page 0 (cursorStack non-empty), reset
the cursor stack and reload from page 0 so the operator lands
on a non-empty view. The common case — page has many entries —
keeps the operator's position via the existing cursorStack top.
At page 0 the cursor is already undefined and the empty state
is unambiguous, so no fallback is needed there.
Caller audit (semantic-change rule):
onDelete: the only caller is the ObjectDetailModal's Delete
button (line 339 in the same file). The semantic change is
the reload cursor: defaults to currentCursor (cursorStack
top), falls back to undefined under the empty-page condition.
No external consumer; the page/cursorStack state both stay
internal to S3ObjectsTab.
loadPage: the new call site uses (undefined, prefix) — same
signature, no behavioural surprise. The cursorStack reset
matches the [bucket] effect's reset pattern (Codex P2 fix
from r2 already established the 'cursor stack consistency
with page state' invariant).
Self-review:
Data loss: none. Display-state fix; the deleted object is
gone either way.
Concurrency: setCursorStack + loadPage run in the same
handler; no race with parallel updates.
Performance: one extra setState call on the empty-page
delete path.
Consistency: matches r3's fail-closed-on-error shape (clear
stale state, show fresh canonical view).
Test coverage: SPA test harness absent; manual exercise
confirms 101-object scenario shows page 1 after deleting
the only page-2 object.
npm run lint - passes
npm run build - passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 5 addresses Codex P2 (delete that empties current page leaves UI on a misleading 'empty bucket' state when earlier pages still have content).

onDelete now checks objects.length <= 1 && common_prefixes.length == 0 pre-reload; if true AND we're past page 0 (cursorStack non-empty), reset cursorStack and reload from page 0 so the operator lands on a non-empty view. Common case (many entries per page) keeps the operator's position via the existing cursorStack top.

Caller audit: onDelete has one caller (ObjectDetailModal Delete button); loadPage signature unchanged.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 1m 55s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 5)

  • Read S3ObjectsTab.tsx to verify Round 5 fix
  • Analyze post-delete cursor-reset logic and edge cases
  • Post review

Round 5 fix — verified correct ✅

S3ObjectsTab.tsx:238-245

constwouldEmptyPage=(page?.objects?.length??0)<=1&&(page?.common_prefixes?.length??0)===0;if(wouldEmptyPage&&cursorStack.length>0){setCursorStack([]);voidloadPage(undefined,prefix);}else{voidloadPage(cursorStack[cursorStack.length-1],prefix);}

The logic is sound. Walking through the key cases:

Scenario 1 — targeted case (101 objects, page 2 has 1 object, delete it):
page.objects.length = 1wouldEmptyPage = true. cursorStack.length = 1 → condition fires. setCursorStack([]) + loadPage(undefined, prefix) → page-0 reload. Operator lands on the 100-object first page. ✅

Scenario 2 — common case (page has many entries):
page.objects.length = 50wouldEmptyPage = false. Falls to else → reloads with current cursor. Operator stays on the same page. ✅

Scenario 3 — page has 1 object + 3 folders, delete the object:
wouldEmptyPage = (1 <= 1) && (3 === 0) = false. Falls to else → reloads with current cursor. Page will show 3 folders + 0 objects — not misleading. ✅

Scenario 4 — page 0 with only 1 object, delete it:
wouldEmptyPage = true, but cursorStack.length = 0 → condition does not fire. Falls to else: loadPage(cursorStack[-1] = undefined, prefix) — page-0 reload. The empty result correctly shows "This bucket is empty." or "This folder is empty." ✅

Scenario 5 — multi-page bucket, page 2 has 1 object + next_continuation_token set (page 3 exists):
wouldEmptyPage = true, cursorStack.length > 0 → reset to page 0. The operator can paginate through the now-correctly-packed listing. Conservative but correct — stale token for the middle-of-deletion gap would not be reliable anyway. ✅

Implementation notes:

  • loadPage(cursor, prefix) takes cursor as a parameter — it does not read cursorStack from closure — so the setCursorStack([]) and the loadPage(undefined, prefix) call are independent and order-safe. The stack reset is a UI state update; the network call uses the explicitly passed undefined cursor.
  • loadPage starts with listAbortRef.current?.abort(), so any stale in-flight request is cancelled before the page-0 reload begins. The abort/generation-token guards from r1 remain intact.
  • page?.objects?.length with optional chaining on objects matches the pattern in objectCount (line 254) and loadError path (line 94). Consistent with the rest of the file.
  • onDelete is only called from the ObjectDetailModal Delete button (line 454), which is only rendered when detail !== null, which implies page !== null at the time of the click. So page?.objects?.length ?? 0 with fallback-to-0 is defensive-but-safe — if somehow page became null, the 0 <= 1 condition would trigger a page-0 reload, which is the safe choice.

Confirmed correct from prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2[bucket] effect resets prefix to "" before [bucket, prefix] fires; listAbortRef cancels the stale call.
  • r3setPage(null); setCursorStack([]) in the non-abort catch path; fail-closed across all five call sites.
  • r4 — detail modal state (detail, confirmDelete, detailError) cleared on bucket change; breadcrumb key={target}.

Remaining items (unchanged from r4 — all Low/pre-existing, none block merge)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data&&<S3ObjectsTabbucket={name}/>}

A 503 during a Raft leader flip hides the Objects tab silently even though listObjects/downloadObject would succeed independently. Pre-existing pattern.

Low — Download anchor + session expiry

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 5 is correct and minimal. The wouldEmptyPage snapshot correctly identifies the empty-tail-page scenario using pre-delete state, the cursorStack.length > 0 guard exempts page 0 where the empty state is unambiguous, and the page-0 fallback is deterministic and stale-token-free. The five-round fix suite (abort races r1, cursor-advance r1, putObject res.ok r1, stale-prefix r2, fail-closed on error r3, stale-modal r4, breadcrumb key r4, cursor-reset-on-empty-delete r5) is complete. Ready to merge after #814 lands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:3da10e1f9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +299 to +300
{(objectCount > 0 || folderCount > 0) && (
<table className="table">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hide stale rows while object-list fetch is in flight

This table keeps rendering page rows even when loading is true, so after changing bucket/prefix the previous context’s rows remain clickable until the new listObjects call finishes. In that window, clicking a stale row opens the modal under the new bucket prop, and Download/Delete can target the wrong bucket/key combination if names overlap. Guarding row rendering during refreshes (or clearing page at request start for context changes) avoids cross-context actions from stale data.

Useful? React with 👍 / 👎.

bootjp added a commit that referenced this pull request May 23, 2026
…sor-on-success (PR #815 r3)
Three findings from Claude review on PR #815 r2.
1) Codex P1 (post-r1) - modal not reset on table change. The
parent DynamoDetail page uses useApiQuery, which doesn't
clear data on dep change, so DynamoItemsTab stays mounted
continuously across /dynamo/tables/A -> /dynamo/tables/B
navigation. Without an explicit reset, the table prop flips
to B but modalKey (set by openView) still holds keyFromA.
onSave then calls api.putItem(B, keyFromA, body), silently
overwriting a colliding key in table B.
Fix: the [table, pageSize] useEffect now inlines the same
reset closeModalForce performs (setModalMode/Item/Key/Error
all to null). Inlined rather than calling closeModalForce
because that helper is declared later in the file - the
effect runs at runtime via closure but the inline keeps the
reset list visible at the call site.
Same cross-context class as the S3 ObjectsTab detail-modal-
on-bucket-change fix on PR #816 r4.
2) Codex P2 - React key collision for binary primary keys.
describePrimaryKey renders binary attributes as
'<binary ~NB>'. Two distinct binary values with the same
byte length produce the same string; React sees duplicate
keys; reconciliation is undefined and rows can bind to
wrong onOpen handlers (cross-row click hijack).
Fix: React key is now
encodeAdminItemKey(extractPrimaryKey(item, partitionKey,
sortKey) ?? {}). encodeAdminItemKey returns the full
base64url-encoded JSON of the key map - binary bytes encode
verbatim, collision-free within a scan page.
3) Minor - comment promised cursor-advance after success, code
advanced optimistically. Now matches: loadPage returns a
success boolean (mirrors the S3 ObjectsTab pattern from PR
#816 r1), onNextPage awaits and only calls setCurrentCursor
on ok=true. A transient Next failure now keeps currentCursor
at the prior page; Refresh re-fetches the visible page, not
the failed one.
Caller audit (semantic-change rule):
loadPage: signature changed void -> Promise<boolean>. Five
callers - init effect, onNextPage, onRefresh, post-write
reload, post-delete reload. Only onNextPage uses the new
return value; the other four void-cast or ignore. Adding
a return value is strictly additive for the void callers.
React key change: internal to the JSX render block; no
external consumers.
useEffect modal reset: state-setter sequencing; no public
API change.
Self-review:
Data loss: closes a write-to-wrong-table vector on modal
save AND a cross-row click-hijack vector on duplicate-
binary-key pages. Both real-world failure modes.
Concurrency: cursor advance now matches S3 tab - both
advance only on success.
Performance: encodeAdminItemKey per row is one extra
base64+JSON encode (~10-100us); negligible vs. the
scan latency.
Consistency: DynamoItemsTab now matches S3ObjectsTab on
all three patterns (success-only cursor advance, modal
reset on parent context change, collision-free row keys).
Test coverage: SPA test harness absent; manual exercise
confirms /dynamo/A -> /dynamo/B closes any open modal,
and a binary-keyed table renders distinct rows for each
key.
npm run lint - passes
npm run build - passes
…d (PR #816 r6)
Codex P1 on PR #816 r5. The bucket / prefix change effect set
listAbortRef to cancel in-flight requests but left the page
state populated, so during the listObjects load window the
previous context's rows remained visible and clickable. A
click in that window opens the detail modal carrying the old
bucket-a object key while the bucket prop has already flipped
to bucket-b — Download / Delete then target
(bucket=b, key=staleKeyFromA), a cross-bucket data-integrity
vector when key paths overlap.
Fix: the [bucket, prefix] useEffect now calls setPage(null)
synchronously before issuing loadPage. Stale rows disappear
the instant the context changes; the table renders the
loading state during the new fetch. Side effects:
- Refresh / Next / post-write reloads are unaffected; they
bypass this effect and call loadPage directly.
- Initial mount is unaffected (page is already null).
- A bucket / prefix change shows 'Loading...' instead of
the previous context's rows for the network round-trip
duration; this is the intended fail-closed UX.
Caller audit (semantic-change rule):
loadPage signature unchanged. The useEffect adds one
setPage(null) call before the existing setCursorStack +
loadPage sequence. Page-state semantics unchanged: null
still means 'no data loaded'; setPage(result) on success
still populates rows. The only new behaviour is that the
null window now spans the full context-change → loadPage-
resolve duration instead of just up to loadPage's first
setState. No external consumer of the page state.
Same fail-closed shape the prefix-reset (r2) and modal-
reset (r4) effects established. Future context changes
that touch bucket/prefix automatically get the same
stale-row protection.
npm run lint - passes
npm run build - passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 6 addresses Codex P1 (stale rows clickable during context-change loading window).

The [bucket, prefix] useEffect now calls setPage(null) synchronously before loadPage. Stale rows disappear the instant the context changes; the table renders the loading state during the new fetch. Refresh / Next / post-write reloads bypass this effect, so legitimate same-context refreshes don't flash empty.

Caller audit: loadPage signature unchanged. The added setPage(null) is one synchronous setter before the existing setCursorStack + loadPage sequence. No external consumer of page state.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 15s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 6)

  • Reading changed files
  • Analyzing Round 6 fix (setPage(null) on context change)
  • Checking remaining items from prior rounds
  • Posting review

Round 6 fix — verified correct ✅

S3ObjectsTab.tsx:149-154 — one new line at the top of the [bucket, prefix] useEffect:

useEffect(()=>{setPage(null);// <-- new in r6setCursorStack([]);voidloadPage(undefined,prefix);// eslint-disable-next-line react-hooks/exhaustive-deps},[bucket,prefix]);

Execution sequence is correct. All four synchronous setters in the effect body (and at the start of loadPage before its first await) are batched by React 18 automatic batching into a single re-render:

  1. setPage(null) — clears objectCount and folderCount to 0 → table body does not render
  2. setCursorStack([]) — resets pagination
  3. Inside void loadPage(...) before the first await: setLoading(true), setLoadError(null)

Because all four setters execute synchronously before any await, React 18 batches them into one commit. The user sees a single transition from "old rows" → "Loading…" with no intermediate empty-state flash. The "empty bucket" message is guarded by {!loading && objectCount === 0 && ...} — with loading=true batched in the same commit, this guard correctly suppresses it. ✅

The cross-bucket click vector is fully closed. Without r6, the window between a bucket change and loadPage resolving left old page populated: objectCount > 0 → table rendered → clicking a row called openDetail(objFromBucketA), setting detail to the stale object while the bucket prop had already flipped to bucket-B. onDelete uses the bucket prop directly (api.deleteObject(bucket, detail.key) = api.deleteObject(bucketB, staleBucketAKey)). With r6, setPage(null) batches with setLoading(true) so the table disappears before any click can land. ✅

Refresh / Next / post-write call sites are unaffected. Those call loadPage directly without going through the [bucket, prefix] effect, so they do not trigger setPage(null). Same-context refreshes show the previous data while loading (the prior behavior), which is the correct UX for in-place refreshes. ✅

Initial mount is a no-op.page is already null at mount; setPage(null) is a redundant but harmless call. ✅

Interaction with the [bucket] effect (r2 + r4) is unchanged. On bucket change, [bucket] fires first (scheduling setPrefix("")), then [bucket, prefix] fires with oldPrefix (now calls setPage(null) + loadPage(undefined, oldPrefix)). The setPrefix("") re-render causes [bucket, prefix] to fire again with prefix="", which aborts the first call via listAbortRef and issues the correct loadPage(undefined, ""). The r6 setPage(null) runs in both fires — the first is superseded immediately by the second. No regression. ✅


Confirmed correct from all prior rounds

  • r1listAbortRef + generation token (abort races), cursor advance on success only, putObject res.ok.
  • r2[bucket] effect resets prefix to "" before [bucket, prefix] fires.
  • r3setPage(null); setCursorStack([]) in the non-abort catch path; fail-closed across all five call sites.
  • r4 — detail modal state cleared on bucket change; breadcrumb key={target}.
  • r5wouldEmptyPage snapshot resets to page 0 when delete empties a non-first page.

Remaining items (pre-existing, out of scope — confirmed in trigger comment)

Low — Objects tab still gated on detail.data (S3Detail.tsx:112)

{detail.data&&<S3ObjectsTabbucket={name}/>}

Pre-existing pattern shared with the rest of the SPA; not introduced by this PR.

Low — Download anchor + session expiry

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 6 is correct and minimal. The one-line addition (setPage(null)) in the [bucket, prefix] useEffect closes the remaining stale-row clickability window identified in Codex P1 r5. React 18 automatic batching ensures setPage(null) and setLoading(true) land in the same commit, producing a direct "rows → Loading…" transition with no empty-state flash. The six-round fix progression is complete:

RoundFix
r1Abort races (listAbortRef + generation token), cursor-advance on success, putObject res.ok
r2Stale prefix on bucket change
r3Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4Detail modal cross-bucket leak + breadcrumb key={target}
r5Cursor reset when delete empties current page
r6setPage(null) on context change — closes stale-row click window

Ready to merge after #814 lands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:f01525f097

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// would 400 before reaching the storage layer.
const key = prefix + file.name;
await api.putObject(bucket, key, file, file.type || "application/octet-stream");
void loadPage(cursorStack[cursorStack.length - 1], prefix);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh object list with current context after upload

After await api.putObject(...), this callback reloads using the prefix and cursorStack captured when the file was selected, not the current view context. If an operator navigates to another prefix or bucket while a long upload is in flight, the completion handler can repopulate page with the old listing, leaving rows that no longer match the breadcrumb/route and enabling follow-up download/delete actions against unintended keys. Reload should use the latest bucket/prefix/cursor (or skip if context changed) before mutating list state.

Useful? React with 👍 / 👎.

bootjp added a commit that referenced this pull request May 23, 2026
…mount (PR #815 r4)
Codex P2 on PR #815 r3. useApiQuery does NOT clear data on dep
change, so a /dynamo/tables/A -> /dynamo/tables/B navigation
left the DynamoDetail page rendering with the OLD detail.data
(name=A, partition_key/sort_key from A's schema) for the brief
window until the new describe response landed. During that
window:
<DynamoItemsTab table={name=B}
partitionKey={detail.data.partition_key from A}
sortKey={detail.data.sort_key from A} />
The tab dispatched a scan against table B but the
extractPrimaryKey calls inside used A's partition/sort key
names — wrong attributes extracted, edit/delete derivation
failed (or, worse, targeted unrelated columns).
The r3 internal modal reset (DynamoItemsTab.tsx [table,
pageSize] effect) handled the case where the tab itself stayed
mounted across table changes, but the props were still stale.
This fix moves the boundary one level up: the parent gates the
mount on detail.data.name === name so the tab never sees a
table/schema mismatch. The key={name} prop forces a fresh
component instance on route change so the tab's internal state
also resets cleanly.
Caller audit (semantic-change rule):
No function signatures changed. The gating condition narrows
the render predicate; any consumer downstream of the tab is
unaffected (the tab still receives consistent table/key
props or doesn't mount at all). The key={name} remount means
the tab's useState slices reset on navigation rather than
carrying across — same fail-closed shape as the [bucket]
effect in S3ObjectsTab.
Self-review:
Data loss: closes the wrong-table edit/delete vector during
the describe-loading window.
Concurrency: no shared state changes.
Performance: one extra prop comparison per render; remount
discards the tab's in-flight scan via the existing
AbortController cleanup effect.
Consistency: matches the S3DetailPage pattern (where
S3ObjectsTab is already mounted via {detail.data && ...})
once that gets the same name-match guard. Filing the
parallel s3 fix as a separate one-liner on the #816
branch since the same useApiQuery race applies there.
Test coverage: SPA test harness absent; manual exercise
confirms /dynamo/A -> /dynamo/B no longer renders A's
item rows under B's table prop.
npm run lint - passes
npm run build - passes
…ey remount (PR #816 r7)
Parallel of the PR #815 r4 fix on the Dynamo side. useApiQuery
keeps the previous bucket's detail.data around while a new
fetch is in flight, so a /s3/A -> /s3/B navigation briefly
mounts S3ObjectsTab with bucket=B while the describe response
for B is still loading. During that window:
- The tab's own [bucket] effect (added in r2/r4) resets prefix,
modal state, and cursor.
- The [bucket, prefix] effect (added r6) clears page to hide
stale rows during the load.
Those internal protections all fire on the bucket prop change.
But the parent S3DetailPage was still rendering the tab with
detail.data from the OLD bucket - the tab's internal protections
re-fire correctly, but for one render cycle the operator could
observe stale-context content under the new route. Gating on
bucket_name === route name eliminates that window entirely;
key={name} forces a clean component instance on navigation so
the tab's AbortController cleanup cancels any in-flight scan.
Caller audit (semantic-change rule):
No function signatures changed. The gating condition narrows
the render predicate. key={name} swap remounts the tab cleanly
on route change; the tab's existing cleanup effect handles
the in-flight abort. No external consumer downstream.
npm run lint - passes
npm run build - passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 7 lands the parallel of #815 r4: gate <S3ObjectsTab> on detail.bucket_name === name + key={name} remount. Closes the cross-bucket render window that the parent's useApiQuery race left open even though the tab's internal [bucket] effect was correct.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 34s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 7)

  • Reading changed files
  • Analyzing Round 7 fix (detail.bucket_name === name gate + key={name} remount)
  • Checking remaining items from prior rounds
  • Posting review

Round 7 fix — verified correct ✅

S3Detail.tsx:112-127 — two changes in one commit:

// Before{detail.data&&<S3ObjectsTabbucket={name}/>}// After{detail.data&&detail.data.bucket_name===name&&(<S3ObjectsTabkey={name}bucket={name}/>)}

The bug being closed:

useApiQuery keeps the previous bucket's detail.data populated while a new fetch is in flight. During a /s3/A → /s3/B navigation, name (route param) flips to "B" but detail.data.bucket_name is still "A". The old guard detail.data && was truthy — so <S3ObjectsTab bucket="B" /> mounted even though the describe response hadn't confirmed bucket B yet. The tab's own [bucket] (r2/r4) and [bucket, prefix] (r6) effects all fire correctly on the bucket prop change, but there's still a render window where operators could observe stale-context content.

Why detail.bucket_name === name closes the window:

Once this guard is in place, the Objects tab is simply not rendered during the stale-data window. detail.data.bucket_name === "A" and name === "B" → guard is false → tab hidden → no rows to click. When the describe response for B arrives (detail.data.bucket_name === "B"), the guard passes and the tab mounts fresh. The gate is fail-safe: if bucket_name is absent from the response, undefined === "B" is false — the tab stays hidden rather than rendering with unconfirmed context.

Why key={name} is correct defense-in-depth:

With the gate in place, the tab is unmounted during the B-loading window and remounted when B's describe arrives. The key={name} ensures the remount produces a clean component instance (all state at defaults: prefix="", page=null, cursorStack=[], etc.) rather than reusing a potentially dirty instance. It also fires the unmount cleanup effect, which calls listAbortRef.current?.abort() — cancelling any in-flight listObjects(A) scan before the new instance starts its initial listObjects(B) call.

Interaction with the r2/r4 [bucket] effect:

With key={name}, the [bucket] effect's primary job (reset state on bucket change mid-instance) is now preempted by the full remount. Both mechanisms are present: key resets eagerly via unmount/remount; the [bucket] effect is defense-in-depth for any code path where the prop changes without a key change. No regression — the effect still fires correctly on initial mount.

Interaction with r6 setPage(null) in [bucket, prefix]:

The r6 setPage(null) addressed the case where the same component instance received a new bucket prop and needed to hide stale rows during the loading window. With key={name}, that scenario is replaced by a full remount (page starts at null by default). The setPage(null) remains in the effect and is still needed for prefix changes within the same bucket (folder navigation), where key doesn't change and the effect is the only thing clearing stale rows.

Parallel to PR #815 r4:

The shape is identical to the DynamoDB side (DynamoDetail.tsx: detail.data && detail.data.table_name === name && <DynamoItemsTab key={name} table={name} />). Consistent pattern — good for future auditability.


Nit — Multi-line comment in JSX

S3Detail.tsx:113-125 — the inline JSX comment is 12 lines. CLAUDE.md asks for max one short line; this content belongs in the commit message (where it already is, verbatim). If you want to keep anything in-code, a single line suffices:

{/* Tab suppressed during describe-loading window; key forces clean remount on route change */}<S3ObjectsTabkey={name}bucket={name}/>

Not a blocker — the commit message covers it thoroughly.


Confirmed correct from all prior rounds

RoundFix
r1listAbortRef + generation token (abort races), cursor-advance on success, putObject res.ok
r2Stale prefix on bucket change
r3Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4Detail modal cross-bucket leak + breadcrumb key={target}
r5Cursor reset when delete empties current page
r6setPage(null) on context change — closes stale-row click window
r7detail.bucket_name === name gate + key={name} — closes cross-bucket render window at parent level

Remaining items (pre-existing, out of scope)

Low — Objects tab hidden on non-404 describe error (S3Detail.tsx:112)

detail.data is null on a 503, so the Objects tab is hidden silently even though listObjects/downloadObject would succeed independently. The r7 gate makes this marginally stricter (describe must also return the correct bucket_name), but the trade-off is correct: an unconfirmed bucket should not render the tab. Pre-existing; acceptable as-is.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 7 is correct and complete. The detail.bucket_name === name guard eliminates the parent-level cross-bucket render window that the tab's own internal effects could not fully prevent on their own. key={name} ensures clean instance state and in-flight abort on every route change. The pattern is a direct parallel of PR #815 r4, maintaining consistency across the DynamoDB and S3 tabs. The seven-round fix progression is complete. Ready to merge after #814 lands.

bootjp added a commit that referenced this pull request May 23, 2026
…(PR #815 r5)
Codex P1 on PR #815 r4 caught the page-size-change case my
r4 parent-level key remount didn't cover. The table-switch case
is handled by DynamoDetail's key={name} remount (full unmount
+ remount, page is freshly null on first render), but pageSize
changes keep the same component mounted; without an explicit
setPage(null) in the [table, pageSize] effect, the previous
page-size's rows stay visible during the new scan's loading
window. A click in that window opens the modal with rows that
don't match the page-size context the operator just switched to.
Fix: add setPage(null) to the effect alongside the existing
modal/cursor resets. Mirrors the S3 ObjectsTab [bucket, prefix]
effect from PR #816 r6.
Caller audit: no signature changes. setPage(null) is one new
synchronous setter before the existing loadPage(undefined) call.
The component still treats page=null as 'no data' uniformly.
npm run lint - passes
npm run build - passes
…igation (PR #816 r8)
Codex P1 on PR #816 r7. onFilePicked is an async closure that
can outlive a folder/bucket navigation when the upload is
large. The previous code:
await api.putObject(bucket, key, file, ...)
void loadPage(cursorStack[cursorStack.length - 1], prefix)
uses closure-captured prefix/cursorStack — so a navigation
during the upload would have the completion handler reload
the OLD prefix's listing under the NEW context. The bucket
case is mostly covered by the parent's key={name} remount
(unmount cancels via the AbortController), but prefix changes
within the same bucket keep the component mounted.
Fix:
1. Snapshot uploadBucket / uploadPrefix / uploadCursor at
click time (defines the upload's intent).
2. contextRef updates on every render to hold the LATEST
bucket/prefix — the standard React-async-callback ref
pattern; closures see stale state, refs see current.
3. After the upload resolves, compare snapshot to current
via contextRef. If matched, reload normally. If diverged,
skip the reload AND the error surface — the [bucket,
prefix] effect has already kicked off the right scan
for the operator's new context, and surfacing an error
for an upload they walked away from would attach to an
unrelated screen.
The successful PUT against (uploadBucket, uploadPrefix+key)
is durable regardless — only the listing refresh is gated.
Caller audit: onFilePicked is only called from the hidden
<input type='file'> onChange (file picker). No external
consumer; the closure semantics shift is internal.
contextRef pattern is the same as the listAbortRef / scanAbortRef
pattern used elsewhere in the file — refs updated on every
render to let async callbacks see current state. No new
abstraction introduced.
npm run lint - passes
npm run build - passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 8 addresses Codex P1: post-upload reload using closure-captured prefix/cursorStack.

onFilePicked now snapshots uploadBucket / uploadPrefix / uploadCursor at click time and compares against contextRef.current after the upload resolves. If the operator navigated mid-flight (long uploads), skip the reload AND the error surface — the [bucket, prefix] effect already kicked off the right scan for the new context, and an error banner attached to an unrelated screen would be misleading.

contextRef is the standard ref-on-every-render pattern; mirrors the listAbortRef / scanAbortRef shape elsewhere in the file.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 2m 2s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 8)

  • Reading changed files
  • Analyzing Round 8 fix (post-upload reload with context snapshot)
  • Checking remaining items from prior rounds
  • Posting review

Round 8 fix — verified correct ✅

S3ObjectsTab.tsx:66-67, 216-247

The contextRef + snapshot pattern is the right approach. Walking through the mechanics:

contextRef wiring:

constcontextRef=useRef({ bucket, prefix });contextRef.current={ bucket, prefix };

The assignment on every render (outside useEffect) ensures the ref always holds the latest React state. Closures see the stale values captured at their call time; contextRef.current at resolution time sees whatever the component last rendered with. This is the canonical pattern for bridging async callbacks to current state.

Snapshot at click time:

constuploadBucket=bucket;constuploadPrefix=prefix;constuploadCursor=cursorStack[cursorStack.length-1];

Three variables locked to the upload's intended context. api.putObject uses uploadBucket and key = uploadPrefix + file.name, so the upload always targets the correct resource regardless of subsequent navigation. Good: the capture is before the await, not inside the try body, so no partial-capture risk.

Post-success gate:

constcur=contextRef.current;if(uploadBucket===cur.bucket&&uploadPrefix===cur.prefix){voidloadPage(uploadCursor,uploadPrefix);}

If the operator navigated prefix mid-flight (same bucket, different folder), uploadPrefix !== cur.prefix — reload is skipped. The [bucket, prefix] effect already issued loadPage(undefined, newPrefix) when prefix changed, so the correct listing is already in flight. No stale rows can land. ✅

Post-error gate — same check:

constcur=contextRef.current;if(uploadBucket===cur.bucket&&uploadPrefix===cur.prefix){setUploadError(...);}

Correct: silently dropping the error banner when the operator is on a different screen is the right UX choice. The upload either succeeded (durable on the server) or failed (the operator can retry from the correct context). An error banner attached to an unrelated screen would be confusing. ✅

Bucket-change case (belt-and-braces):
The parent's key={name} remount (r7) unmounts the component when name changes. The unmount cleanup effect calls listAbortRef.current?.abort(). The onFilePicked closure still holds a reference to the old component's contextRef (whose .current is frozen at the last render: { bucket: "A", prefix: "..." }). After unmount, renders stop, so contextRef.current stays at bucket-A values. uploadBucket === cur.bucket evaluates true — the post-upload reload calls loadPage(...) on the old component's state setters, which React 18 silently drops after unmount. No harm done; the new component instance (bucket B) starts clean. The snapshot check is redundant here but not wrong. ✅

uploadCursor semantics:
The reload uses the cursor captured at upload time, not cursorStack[cursorStack.length - 1] (current). If the operator paginated while a large upload was in progress (e.g., started on page 1, paginated to page 2 during upload), the post-upload reload brings them back to page 1. The newly uploaded file appears there. One minor UX note: the operator is snapped back to the upload's origin page, which might be surprising if they've paginated several pages forward. Not a correctness issue — the upload is durable and the listing reflects the new reality.

finally { setUploadBusy(false) } — always runs:
Whether the context check passes or fails, uploadBusy resets to false. The Upload button re-enables regardless of navigation. Correct — the upload work is done either way. ✅


One nit — comment style (pre-existing pattern, not r8-specific)

S3ObjectsTab.tsx:60-65, 207-214, 226-234, 240-242

The comments in r8 (and throughout the file from prior rounds) reference PR rounds and Codex issue numbers inline ("Codex P1 on PR #816 r7", "PR #816 r7 caught the post-upload reload using stale prefix/cursor"). CLAUDE.md explicitly says: "Don't reference the current task, fix, or callers … since those belong in the PR description and rot as the codebase evolves." This pattern was established in r1 and carried forward consistently. Worth a cleanup pass before merge but not a blocker — the commit messages already have this context.


Confirmed correct from all prior rounds

RoundFix
r1listAbortRef + generation token (abort races), cursor-advance on success, putObject res.ok
r2Stale prefix on bucket change
r3Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4Detail modal cross-bucket leak + breadcrumb key={target}
r5Cursor reset when delete empties current page
r6setPage(null) on context change — closes stale-row click window
r7detail.bucket_name === name gate + key={name} — closes cross-bucket render window at parent level
r8contextRef + upload-time snapshot — skip post-upload reload and error surface on mid-flight navigation

Remaining items (pre-existing, out of scope)

Low — Objects tab hidden on non-404 describe error (S3Detail.tsx:112)

A 503 during a Raft leader flip hides the Objects tab silently even though list/download would succeed independently. Pre-existing pattern; the r7 gate (detail.bucket_name === name) makes this marginally stricter but the trade-off is correct.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 8 is correct. The contextRef + upload-time snapshot pattern cleanly solves the stale-closure problem for post-upload reloads. The two context gates (success and error paths) are correctly placed after the await, use contextRef.current (latest state) compared against the click-time snapshot, and cover both the prefix-change and bucket-change scenarios. The bucket-change case is belt-and-braces on top of the r7 parent remount. The eight-round fix progression is complete. Ready to merge after #814 lands.

bootjp added a commit that referenced this pull request May 23, 2026
…#818 r1)
Two style notes from Gemini medium x2 / Claude bot on PR #818.
1) CLAUDE.md violation: the inline comment referenced PRs
#813/#814/#815/#816 and 'today' — transient context that
rots and doesn't belong in source comments. Per
CLAUDE.md: 'Don't reference the current task, fix, or
callers ... those belong in the PR description'. The
commit message and PR description still carry the cross-
PR diagnostic context for git archeology.
2) Eventually deadline now derives from initialTTL
(initialTTL + 3*time.Second) rather than a hardcoded 5s.
If a future TTL adjustment lands, the assertion window
stays valid without a parallel update.
Trimmed comment focuses on the technical justification:
- WHY 2s TTL (SET-ack latency on 3-node Raft under -race)
- WHY ttl+3s deadline (poll cadence + Raft-replicated DEL
latency on expiry)
No behavior change to the test. initialTTL=2s + deadline=5s
(2+3) matches the previous 2s + 5s. Tested locally
with -race -count=2 (7.4s wall) — passes both runs.
go test -race -count=2 -timeout=120s ./adapter/... in scope: passes
golangci-lint: clean
bootjp added a commit that referenced this pull request May 23, 2026
…I flake (#818)
## Summary
Fixes the recurring `TestRedis_ExpiredKey_BecomesInvisible` CI flake
that has been blocking unrelated admin PRs (#813, #814, #815, #816) all
day.
## Root cause
The test sets a key with `PX 200` (200ms TTL) then immediately reads it
back:
```go
require.NoError(t, rdb.Do(ctx, "SET", "expiry:short", "v", "PX", "200").Err())
got, err := rdb.Get(ctx, "expiry:short").Result()
require.NoError(t, err) // ← fails: redis.Nil
require.Equal(t, "v", got, "key must be visible before expiry")
```
The `SET` goes through a 3-node Raft cluster: client → leader proposes →
quorum apply → leader applies → response. Under `-race` on CI runners
this round-trip can take 100–250ms. The TTL clock starts at the moment
the leader applies, so by the time `SET` returns OK on a slow CI runner,
the key may have <50ms of TTL left. The immediately-following `GET` then
loses the race and hits `redis.Nil`.
That this is the bug — not anything in any of #813-#816 — is locked in
by the cross-PR failure pattern: the flake fired today on PRs touching
only `internal/admin/` and `web/admin/`, with zero code overlap with
`adapter/`.
## Fix
`PX 200 → PX 2000`. 2s is comfortably past the worst observed SET-ack
latency (~250ms on CI) while keeping the test under a 7s wall-clock
budget (2s TTL + 5s `Eventually` deadline, raised from 1s).
The test's intent — verify expired keys become invisible to subsequent
GETs — is preserved exactly. Only the timing constants change; the
assertions are unchanged.
## Self-review (5 passes)
1. **Data loss** — none. Test-only constants.
2. **Concurrency** — closes a wall-clock vs. Raft-apply-latency race in
the test fixture.
3. **Performance** — test now takes ~3s instead of ~250ms locally; still
well under the 30s package timeout.
4. **Consistency** — the broader test still asserts both halves (visible
before expiry, invisible after expiry) of the TTL contract.
5. **Test coverage** — no change to coverage; same assertions,
friendlier timing.
## Test plan
- [x] `go test -race -count=3 -timeout=180s -run
TestRedis_ExpiredKey_BecomesInvisible ./adapter/` — passes 3/3 (10.792s)
- [x] `golangci-lint --config=.golangci.yaml run` — clean
- [ ] CI
## Context
Discovered while running the PR review loop on #813-#817. The Claude
bot's Round 8 review on #813 marked the PR as "Ready to merge once CI
passes" — only the Redis flake was blocking. This fix unblocks #813 (and
the parallel admin PRs) without touching any admin code.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Tests**
* Improved reliability of the Redis TTL compatibility test by adjusting
timing parameters to reduce intermittent test failures.
<!-- review_stack_entry_start -->
[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/818?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Base automatically changed from feat/admin-http-s3-objects to mainMay 23, 2026 22:09
bootjp added a commit that referenced this pull request May 23, 2026
…ix CI flake (#819)
## Summary
Fixes the recurring
`TestSQSServer_Throttle_NoOpSetQueueAttributesPreservesBucket` CI flake
that has been hitting the same admin PR series (#813, #815, #816) that
#818 just unblocked.
## Root cause
The test sequence:
1. `SetQueueAttributes` (capacity=10, refill=1/sec)
2. **10 drain sends** (drain the bucket)
3. Sanity-check send → expects 400 (drained)
4. `SetQueueAttributes` with identical values (no-op)
5. **Post-no-op send → expects 400** ← intermittently returns 200
Each step goes through a real Raft propose+apply. Under `-race` on slow
CI runners each round-trip takes 100–250ms. Total wall-clock from start
to step 5 reaches 1.5–2.5s. At **1 token/sec** refill, by step 5 the
bucket has accumulated 1+ tokens and the send returns 200 — falsely
indicating a no-op-invalidate-bypass regression.
The test's intent — verify that a no-op `SetQueueAttributes` does not
reset bucket state — is independent of the refill rate. Slowing the
refill removes the race without changing what's tested.
## Fix
`refill 1/sec → 0.01/sec` (1 token per 100s). Even a 10s test window
can't accumulate to a whole token.
The throttle config validator (`adapter/sqs_catalog.go:163`
`SendRefillPerSecond float64`) accepts fractional values. `0.01 != 0` so
`IsEmpty` returns false and the throttle path is still exercised — only
the refill cadence changes.
## Self-review (5 passes)
1. **Data loss** — none; test-only constant.
2. **Concurrency** — closes a wall-clock vs. refill-rate race in the
test fixture (same shape as the Redis TTL fix in #818).
3. **Performance** — no runtime change; the test still completes in
~1–2s.
4. **Consistency** — the test still verifies the no-op-preserves-bucket
invariant identically. The refill rate isn't load-bearing for the
assertion.
5. **Test coverage** — unchanged. Same drain → sanity → no-op → recheck
sequence; only the refill cadence differs.
## Test plan
- [x] `go test -race -count=2 -timeout=120s -run
TestSQSServer_Throttle_NoOpSetQueueAttributesPreservesBucket ./adapter/`
— passes both (2.1s)
- [x] `golangci-lint --config=.golangci.yaml run` — clean
- [ ] CI
## Context
Second flake-fix PR today (after #818 for Redis TTL). The admin PR
series (#813, #815, #816) keeps hitting unrelated adapter-package flakes
because the admin code lives in `internal/admin/` / `web/admin/` but `go
test ./...` runs the whole tree. Each fix is small and scoped.
@bootjp
bootjp enabled auto-merge May 23, 2026 22:12
Resolves conflict in web/admin/src/api/client.ts: keep both
helpers from the parallel PRs.
- base64UrlEncodeBytes (from this branch, #816): takes a string,
used internally to build S3 object key URL segments.
- encodeAdminItemKey (from main via #815): takes a primary-key
attribute map, used by the Dynamo items methods + exported
for DynamoItemsTab's scan-cursor encoder.
Refactored encodeAdminItemKey to delegate to base64UrlEncodeBytes
(JSON-stringify the map, then byte-encode) — both helpers share
the same wire shape (unpadded base64-url alphabet), so the only
difference is the input type. One implementation, two entry
points.
@bootjp

Copy link
Copy Markdown
OwnerAuthor

Resolved the client.ts conflict after #815 merged. Kept both helpers:

  • base64UrlEncodeBytes(input: string) for S3 object key segments
  • encodeAdminItemKey(key: Record<string, AdminAttributeValue>) for Dynamo items keys

Refactored encodeAdminItemKey to delegate to base64UrlEncodeBytes (JSON.stringify(key) → byte-encode) — both share the same wire shape (unpadded base64-url), so one implementation covers both call sites.

npm run lint / npm run build pass.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:5c0b19d2b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +236 to +237
if (uploadBucket === cur.bucket && uploadPrefix === cur.prefix) {
void loadPage(uploadCursor, uploadPrefix);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reload uploads with the current page cursor

After a successful upload, the tab always refreshes using uploadCursor, which was captured when the file was selected. If the user stays in the same bucket/prefix but navigates to a different page while the upload is in flight, the completion handler will jump the listing back to the old page and overwrite the user’s newer pagination state. This is a stale-context regression similar to the bucket/prefix race, but scoped to in-prefix pagination.

Useful? React with 👍 / 👎.

…fix (PR #816 r9)
Codex P2 on PR #816 r8. The r8 contextRef pattern guarded the
post-upload reload on bucket/prefix mismatch but missed the
in-prefix pagination case. If the operator clicked Next while
the upload was in flight, contextRef.current.bucket and .prefix
still matched (same bucket/prefix), so the gate fell through
and loadPage was called with the captured uploadCursor — jumping
the listing back to the upload's origin page and clobbering the
operator's later pagination state.
Fix: track currentCursor in contextRef alongside bucket/prefix,
and add the equality check for all three axes. The post-upload
reload now only fires when the operator is still on the exact
(bucket, prefix, page) the upload targeted. Same fail-closed
shape as r8 for bucket/prefix: a successful PUT stays durable
on the server; the listing refresh is purely a UX nicety and
skipping it under contention is the right call.
The error-surface gate (catch path) gets the same three-axis
check for symmetry — an upload error on a page the operator
has left wouldn't make sense to attach.
Caller audit: onFilePicked is only invoked from the file-input
onChange. contextRef.current.cursor is updated on every render
via the existing assignment line; the new field is additive.
No external consumer of contextRef.
npm run lint - passes
npm run build - passes
@bootjp

Copy link
Copy Markdown
OwnerAuthor

@claude review

Round 9 addresses Codex P2: r8's contextRef gated the post-upload reload on bucket/prefix mismatch but missed the in-prefix pagination case. If the operator clicked Next mid-upload, the gate fell through and loadPage(uploadCursor, ...) jumped the listing back to the upload's origin page.

Fix: track cursor in contextRef alongside bucket/prefix; gate on all three axes. Both success and error paths use the same three-axis check. Same fail-closed shape as r8 — successful PUT stays durable, listing refresh is purely UX.

Caller audit: onFilePicked is the only consumer; contextRef field is additive.

@claude

claudeBot commented May 23, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 1m 43s —— View job


Code Review: Phase 5 — S3 Objects Tab (Round 9)

  • Reading changed files
  • Analyzing Round 9 fix (three-axis contextRef gate)
  • Checking remaining items from prior rounds
  • Posting review

Round 9 fix — verified correct ✅

S3ObjectsTab.tsx:67-68, 241-248, 255-261

contextRef wiring:

constcontextRef=useRef({ bucket, prefix,cursor: ""asstring|undefined});contextRef.current={ bucket, prefix,cursor: cursorStack[cursorStack.length-1]};

The cursor field is updated on every render via the synchronous assignment on line 68, so by the time any onFilePicked completion handler runs, contextRef.current.cursor always reflects the latest pagination position.

Three-axis gate (success path):

constcur=contextRef.current;if(uploadBucket===cur.bucket&&uploadPrefix===cur.prefix&&uploadCursor===cur.cursor){voidloadPage(uploadCursor,uploadPrefix);}

Walking through the scenarios:

ScenariouploadCursorcur.cursorGate result
Stay on upload's page (page 1)"token1""token1"✅ reload fires, operator sees upload
Navigate to page 2 mid-upload"token1""token2"✅ gate blocks, no stale jump-back
Navigate to page 0 mid-upload"token1"undefined✅ gate blocks
Upload from page 0, still on page 0undefinedundefined✅ reload fires
Upload from page 0, navigated to page 1undefined"token1"✅ gate blocks
Bucket/prefix change (r8 cases)anydifferent bucket/prefix✅ outer axes catch it

The undefined === undefined equality works correctly for the page-0 case. cursorStack[-1] (both in the snapshot and in contextRef.current) evaluates to undefined when the stack is empty — consistent between the capture and the check. ✅

The error path has the identical three-axis guard. No asymmetry between success and error paths. ✅

One nit — initial cursor value

S3ObjectsTab.tsx:67

constcontextRef=useRef({ bucket, prefix,cursor: ""asstring|undefined});

The initial value uses "" (empty string) with an as cast rather than undefined. Semantically, the no-cursor state is undefined (matching the type of cursorStack[-1]). The "" is harmless in practice — contextRef.current is synchronously overwritten on line 68 before any async callback could observe it — but the explicit cast and the semantic mismatch are unnecessary noise. A cleaner initialization:

constcontextRef=useRef<{bucket: string;prefix: string;cursor: string|undefined}>({ bucket, prefix,cursor: undefined});

Not a blocker.


Confirmed correct from all prior rounds

RoundFix
r1listAbortRef + generation token (abort races), cursor-advance on success, putObject res.ok
r2Stale prefix on bucket change
r3Fail-closed on error (setPage(null) + setCursorStack([]) in catch)
r4Detail modal cross-bucket leak + breadcrumb key={target}
r5Cursor reset when delete empties current page
r6setPage(null) on context change — closes stale-row click window
r7detail.bucket_name === name gate + key={name} — closes cross-bucket render window at parent level
r8contextRef + upload-time snapshot — skip post-upload reload on mid-flight bucket/prefix navigation
r9Cursor axis added to contextRef gate — skip post-upload reload on mid-flight in-prefix pagination

Remaining items (pre-existing, out of scope)

Low — Objects tab hidden on non-404 describe error (S3Detail.tsx:112)

A 503 during a Raft leader flip hides the Objects tab silently even though listObjects/downloadObject would succeed independently. Pre-existing pattern; the r7 detail.bucket_name === name guard makes this marginally stricter but the trade-off is correct — an unconfirmed bucket should not render the tab.

Low — Download anchor + session expiry (S3ObjectsTab.tsx — download link)

<a download> opened after admin_session expires delivers the 401 JSON as a file with the base64-encoded key as its name. Pre-existing; UX polish only.


Summary

Round 9 is correct. The cursor axis added to contextRef closes the in-prefix pagination race that r8 left open: the three-axis gate (bucket + prefix + cursor) now exactly identifies the (bucket, folder, page) tuple the upload was targeting, and any navigation along any of those three axes causes the post-upload reload to be skipped cleanly. The undefined === undefined path for page-0 cursor works correctly by symmetry between the snapshot and the ref update. The nine-round fix progression is complete. Ready to merge after #814 lands.

@bootjp
bootjp merged commit 12a6c7b into mainMay 23, 2026
9 of 10 checks passed
@bootjp
bootjp deleted the feat/admin-spa-s3-objects branch May 23, 2026 22:56
bootjp added a commit that referenced this pull request May 24, 2026
## Summary
**Phase 6** — close the loop on the Admin Data Browser rollout:
- `git mv docs/design/2026_05_22_proposed_admin_data_browser.md →
_implemented_…`
- Status: `Proposed` → `Implemented`
- Add an Implementation record table at the top of the doc with one row
per PR plus the in-flight design adjustments that came out of PR review
- Rollout plan table grows an Outcome column with the PR number for each
phase
## Sequencing
This PR should merge **last**, after Phases 2a (#805 merged), 2b (#811
merged), 3a (#813), 3b (#814), 4 (#815), 5 (#816) have all landed. The
doc rename codifies the completion, so jumping it ahead would be
misleading.
## Why two commits
The first commit (`82d9387d`) carried the `git mv` but missed the
content edits because they happened after the `git add`. The second
commit (`bb7b77be`) adds the content. They're kept separate per
CLAUDE.md's "never amend, prefer new commits" rule — both are tracked in
the same PR.
## What changed in the doc
Implementation record table:
| Phase | What landed | PR |
|-------|-------------|----|
| 2a | DynamoDB item adapter RPCs | #805 |
| 2b | S3 object adapter RPCs | #811 |
| 3a | DynamoDB item HTTP + bridge | #813 |
| 3b | S3 object HTTP + bridge | #814 |
| 4 | SPA DynamoDetail Items tab | #815 |
| 5 | SPA S3Detail Objects tab + Upload | #816 |
| 6 | This rename | #817 |
Notable in-flight design adjustments documented:
- Empty `L` / `M` Dynamo attributes preserved via custom `MarshalJSON`
(Gemini medium, Codex P1 on #813)
- Path-segment validation decodes via `url.PathUnescape` before
validating; URL-encoded table names work, decoded `/` / `..` still
rejected (Codex P1 on #813)
- The HTTP layer cannot reject body-declares-extra-key-columns;
principled fix tracked separately (Codex P2 on #813)
- SPA bundle output stays `.gitignored`; CI rebuilds from source
## Test plan
- [x] `git mv` preserves history (rename detection 100% similarity on
the first commit)
- [x] No backend touched
- [x] `go test -count=1 ./...` — passes (no code changes)
- [ ] CI
## Phase plan (final state)
- Phase 2a: merged (#805)
- Phase 2b: merged (#811)
- Phase 3a: open (#813) — to merge before this
- Phase 3b: open (#814) — to merge before this
- Phase 4: open (#815) — to merge before this
- Phase 5: open (#816) — to merge before this
- **Phase 6: this PR — to merge LAST**
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Updated admin data browser implementation documentation with shipped
phases and PR references.
* Added implementation record detailing completed phases and notable
design adjustments.
* Enhanced rollout plan with outcome tracking for each completed phase.
<!-- review_stack_entry_start -->
[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/817?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bootjp