From 618ddaf1511b083d6727da8f116084e04cd01bfd Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Thu, 21 May 2026 23:31:41 +0900 Subject: [PATCH 1/5] web/admin: SPA Messages tab + Purge button + DLQ chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of docs/design/2026_05_16_proposed_admin_purge_queue.md: operator-facing UI for the AdminPeekQueue / AdminPurgeQueue HTTP endpoints Phase 4 wired. The queue detail page now: - Surfaces is_dlq with a pill in the header and a "DLQ for" section listing source queues (each linked to its detail page). - Renders a Messages section with a paginated table of currently- visible messages: message-id prefix, sent timestamp, FIFO MessageGroupId (column shown only on FIFO queues), receive count, body preview, original size. Row-click opens a detail modal showing the full body, every attribute, and a "Copy as JSON" button that writes a schema-versioned payload to the clipboard. - Adds a Purge button labelled "Purge messages" or "Purge DLQ" depending on is_dlq. Confirmation modal requires typing the exact queue name; on 429 PurgeQueueInProgress the modal stays open and shows the rate-limit message. The eager full-body fetch (body_max_bytes = 262144 = 256 KiB, the maximum SQS stored size) means the detail modal renders from the row already in memory - no re-peek round-trip on modal open. This eliminates the entire "row disappeared between list and modal" class of failures (concurrent purge, ReceiveMessage by another client, visibility timer started); see design doc §3.5. Page size stays at the documented Phase 5 default of 20 rows, keeping the worst-case response at 5 MiB. ## API client (web/admin/src/api/client.ts) - SqsQueueSummary gains is_dlq + dlq_sources. - New types: SqsPeekedAttribute, SqsPeekedMessage, SqsPeekResult, SqsPeekOptions. - New methods: api.peekQueue(name, opts, signal) and api.purgeQueue(name). peekQueue is signal-aware so the SPA can cancel an in-flight peek on navigation. ## Handler 429 envelope (internal/admin/sqs_handler.go) writePurgeInProgress now uses the canonical "error" key (matching writeJSONError) so the SPA's apiFetch wrapper extracts the AWS- style sentinel into ApiError.code consistently with every other 4xx the admin surface returns. retry_after_seconds remains as an extra field alongside the canonical Retry-After header. The matching test assertion was updated. ## Caller audit (semantic-change rule) The 429 JSON-body key change (code to error) is a wire-shape adjustment with no other consumer yet: PR #797 introduced the endpoint, no client has been built against the prior key. The test was the only reader; updated. ## Verification - cd web/admin && npm run lint (tsc -b --noEmit) passes - cd web/admin && npm run build (vite build) passes - go test -race ./internal/admin/... passes - golangci-lint run ./internal/admin/... 0 issues - The Vite build's hashed-name artifact in internal/admin/dist/index.html is gitignored as a placeholder by the existing .gitignore (lines 47-49); only the placeholder HTML is committed to the repo. --- internal/admin/sqs_handler.go | 12 +- internal/admin/sqs_handler_test.go | 5 +- web/admin/src/api/client.ts | 71 +++++ web/admin/src/pages/SqsDetail.tsx | 412 ++++++++++++++++++++++++++++- 4 files changed, 487 insertions(+), 13 deletions(-) diff --git a/internal/admin/sqs_handler.go b/internal/admin/sqs_handler.go index 1c090d87..bd0ba39c 100644 --- a/internal/admin/sqs_handler.go +++ b/internal/admin/sqs_handler.go @@ -701,7 +701,14 @@ func writeQueuesError(w http.ResponseWriter, err error, logger *slog.Logger, r * // writePurgeInProgress emits the 429 response shape the design doc // §3.4 specifies: Retry-After header (rounded up to whole seconds so // a client retrying exactly at the deadline is guaranteed to clear) -// + JSON body { code, message, retry_after_seconds }. +// + JSON body { error, message, retry_after_seconds }. +// +// The `error` key (not `code`) matches writeJSONError's envelope so +// the SPA's apiFetch wrapper extracts the AWS-style sentinel into +// ApiError.code consistently with every other 4xx the admin surface +// returns. The retry_after_seconds field is in addition to the +// canonical Retry-After header so the SPA does not have to plumb +// response headers through its error path. func writePurgeInProgress(w http.ResponseWriter, err *PurgeInProgressError) { secs := int64(err.RetryAfter / time.Second) if err.RetryAfter%time.Second != 0 { @@ -712,10 +719,11 @@ func writePurgeInProgress(w http.ResponseWriter, err *PurgeInProgressError) { } w.Header().Set("Retry-After", strconv.FormatInt(secs, 10)) w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusTooManyRequests) _ = json.NewEncoder(w).Encode(map[string]any{ - "code": "PurgeQueueInProgress", + "error": "PurgeQueueInProgress", "message": "only one PurgeQueue operation on each queue is allowed every 60 seconds", "retry_after_seconds": secs, }) diff --git a/internal/admin/sqs_handler_test.go b/internal/admin/sqs_handler_test.go index f39fd01a..2309c8ad 100644 --- a/internal/admin/sqs_handler_test.go +++ b/internal/admin/sqs_handler_test.go @@ -347,7 +347,10 @@ func TestSqsHandler_PurgeQueue_RateLimited429(t *testing.T) { require.Equal(t, "43", rec.Header().Get("Retry-After")) var body map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) - require.Equal(t, "PurgeQueueInProgress", body["code"]) + // The "error" key (not "code") matches writeJSONError's envelope + // so apiFetch.ts can extract the AWS-style sentinel consistently + // with every other 4xx error. + require.Equal(t, "PurgeQueueInProgress", body["error"]) require.EqualValues(t, 43, body["retry_after_seconds"]) } diff --git a/web/admin/src/api/client.ts b/web/admin/src/api/client.ts index ad8a318a..c0515c87 100644 --- a/web/admin/src/api/client.ts +++ b/web/admin/src/api/client.ts @@ -210,12 +210,62 @@ export interface SqsQueueSummary { created_at?: string; attributes?: Record; counters: SqsQueueCounters; + // is_dlq is true when another queue's RedrivePolicy resolves its + // deadLetterTargetArn to this queue. Drives the Messages-tab + // framing and the Purge button label ("Purge messages" vs + // "Purge DLQ"). + is_dlq: boolean; + // dlq_sources lists the source queues' names that point at this + // queue, in lexicographic order. Empty when is_dlq is false. + dlq_sources?: string[]; } export interface SqsQueueList { queues: string[]; } +// SqsPeekedAttribute mirrors the typed MessageAttribute shape AWS +// SQS uses. data_type is "String" / "Number" / "Binary" / +// "String.MyCustom" etc.; exactly one of string_value or +// binary_value carries the payload (binary_value is base64 on the +// wire — keep as string here and decode lazily if a preview needs +// raw bytes). +export interface SqsPeekedAttribute { + data_type: string; + string_value?: string; + binary_value?: string; +} + +export interface SqsPeekedMessage { + message_id: string; + body: string; + // body_truncated is true when the wire body was cut to + // body_max_bytes on the server side. body_original_size carries + // the full byte length so the modal can render "showing N of M + // bytes". + body_truncated: boolean; + body_original_size: number; + sent_timestamp: string; + receive_count: number; + group_id?: string; + deduplication_id?: string; + attributes?: Record; +} + +export interface SqsPeekResult { + messages: SqsPeekedMessage[]; + // next_cursor is omitted when the queue is fully drained for the + // current MVCC snapshot. The client treats absence as "no further + // pages" and re-enables the page=1 navigation. + next_cursor?: string; +} + +export interface SqsPeekOptions { + limit?: number; + cursor?: string; + body_max_bytes?: number; +} + // KeyViz wire shapes mirror internal/admin/keyviz_handler.go // (KeyVizMatrix / KeyVizRow). Go []byte fields arrive as // base64-encoded strings via encoding/json — keep them as `string` on @@ -311,6 +361,27 @@ export const api = { apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { signal }), deleteQueue: (name: string) => apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { method: "DELETE" }), + // peekQueue performs a non-destructive sample of currently-visible + // messages. Cursor pagination: pass back the prior call's + // next_cursor on subsequent pages, empty on the first page. The + // server clamps limit to [1, 100] (default 20) and body_max_bytes + // to [256, 262144] (default 4096); pass undefined to use the + // documented defaults. + peekQueue: (name: string, opts?: SqsPeekOptions, signal?: AbortSignal) => + apiFetch(`/sqs/queues/${encodeURIComponent(name)}/messages`, { + query: { + limit: opts?.limit, + cursor: opts?.cursor, + body_max_bytes: opts?.body_max_bytes, + }, + signal, + }), + // purgeQueue drains the queue's messages while leaving the queue + // itself (ARN, RedrivePolicy, tags, attributes) intact. AWS-shape + // 60-second rate limit per queue: a second purge inside the window + // returns 429 + retry_after_seconds in the body. + purgeQueue: (name: string) => + apiFetch(`/sqs/queues/${encodeURIComponent(name)}/messages`, { method: "DELETE" }), keyVizMatrix: (params: KeyVizParams, signal?: AbortSignal) => apiFetch("/keyviz/matrix", { query: { diff --git a/web/admin/src/pages/SqsDetail.tsx b/web/admin/src/pages/SqsDetail.tsx index 2ad32c7c..dd621902 100644 --- a/web/admin/src/pages/SqsDetail.tsx +++ b/web/admin/src/pages/SqsDetail.tsx @@ -1,9 +1,29 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { api } from "../api/client"; +import { + ApiError, + api, + type SqsPeekResult, + type SqsPeekedMessage, +} from "../api/client"; import { Modal } from "../components/Modal"; import { formatApiError, useApiQuery } from "../lib/useApi"; +// kPeekPageSize is the documented Phase 5 default the SPA sends on +// every Messages-tab fetch. The server clamps to [1, 100]; staying +// at 20 keeps the worst-case response (20 rows × 256 KiB) at 5 MiB +// well under typical network and JSON-parse budgets. +const kPeekPageSize = 20; + +// kPeekBodyMaxBytes is the eager full-body fetch size: 256 KiB matches +// SQS's hard cap on stored message size, so the detail modal renders +// directly from the row already in memory — no re-peek round-trip on +// modal open, eliminating the "row disappeared between list and +// modal" failure modes (concurrent purge, ReceiveMessage from another +// client, visibility timer started). Trade is initial fetch size +// for modal-open consistency; design doc §3.5 lays out the reasoning. +const kPeekBodyMaxBytes = 262144; + export function SqsDetailPage() { const { name = "" } = useParams<{ name: string }>(); const detail = useApiQuery((signal) => api.describeQueue(name, signal), [name]); @@ -11,14 +31,10 @@ export function SqsDetailPage() { const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); const navigate = useNavigate(); - // The delete button is gated by the backend's live-role check - // (internal/admin/sqs_handler.go principalForWrite), not the JWT - // role cached in this session. A JWT minted as read_only stays - // read_only in the cookie until logout, but the operator may have - // been promoted to full in the live role store after login — so - // gating the button on session.role would hide it for users who - // are currently authorized. A read_only operator who clicks delete - // gets a 403 from the backend, surfaced in the modal's error area. + // The delete / purge buttons are gated by the backend's live-role + // check (internal/admin/sqs_handler.go principalForWrite), not the + // JWT role cached in this session. See SqsDetail's pre-Phase-5 + // comment for the rationale. const onDelete = async () => { setDeleting(true); @@ -42,6 +58,11 @@ export function SqsDetailPage() { {detail.data.is_fifo ? "FIFO" : "Standard"} )} + {detail.data?.is_dlq && ( + + DLQ + + )} {detail.data && ( + +
+ Showing {result?.messages.length ?? 0} visible message + {(result?.messages.length ?? 0) === 1 ? "" : "s"} + {inFlightCount > 0 && ( + <> ({inFlightCount} currently in-flight, not shown) + )} +
+ {error &&
{error}
} + {loading &&
Loading…
} + {!loading && result && result.messages.length === 0 && ( +
No visible messages.
+ )} + {!loading && result && result.messages.length > 0 && ( + setSelected(m)} + /> + )} +
+ + + +
+ + setSelected(null)}> + {selected && } + + + { + if (purging) return; + setConfirmPurge(false); + setPurgeName(""); + setPurgeError(null); + }} + busy={purging} + > +

{purgeConfirmCopy}

+ + setPurgeName(e.target.value)} + disabled={purging} + autoFocus + /> + {purgeError && ( +
+ {purgeError} + {purgeRetryAfter !== null && ( + <> (retry after about {purgeRetryAfter} seconds) + )} +
+ )} +
+ + +
+
+ + ); +} + +interface MessagesTableProps { + messages: SqsPeekedMessage[]; + showGroup: boolean; + onSelect: (m: SqsPeekedMessage) => void; +} + +function MessagesTable({ messages, showGroup, onSelect }: MessagesTableProps) { + return ( + + + + + + {showGroup && } + + + + + + + {messages.map((m) => ( + onSelect(m)} + > + + + {showGroup && } + + + + + ))} + +
Message IDSentGroupRecvBodySize
+ {m.message_id.slice(0, 8)} + {new Date(m.sent_timestamp).toLocaleString()}{m.group_id ?? ""} + {m.receive_count} + {previewBody(m)}{formatBytes(m.body_original_size)}
+ ); +} + +interface MessageDetailProps { + message: SqsPeekedMessage; + queue: string; +} + +function MessageDetail({ message, queue }: MessageDetailProps) { + const [copied, setCopied] = useState(false); + const onCopyJson = () => { + // Schema version follows design doc §3.5: a future change to the + // export shape bumps schema_version so downstream tooling pins + // the version it expects. + const payload = { + schema_version: 1, + queue, + exported_at: new Date().toISOString(), + message, + }; + void navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( +
+
+
Message ID
+
{message.message_id}
+
Sent
+
{new Date(message.sent_timestamp).toLocaleString()}
+
Receive count
+
{message.receive_count}
+ {message.group_id && ( + <> +
Group ID
+
{message.group_id}
+ + )} + {message.deduplication_id && ( + <> +
Deduplication ID
+
{message.deduplication_id}
+ + )} +
Original size
+
{formatBytes(message.body_original_size)}
+
+
+
+ Body + {message.body_truncated && ( + + Truncated to {formatBytes(message.body.length)} of {formatBytes(message.body_original_size)} + + )} +
+
+          {message.body}
+        
+
+ {message.attributes && Object.keys(message.attributes).length > 0 && ( +
+
Attributes
+
+ {Object.entries(message.attributes).map(([k, v]) => ( +
+
{k}
+
{v.data_type}
+
+ {v.string_value ?? (v.binary_value ? `` : "")} +
+
+ ))} +
+
+ )} +
+ +
+
+ ); +} + function CounterCard({ label, value }: { label: string; value: number }) { return (
@@ -145,3 +525,15 @@ function CounterCard({ label, value }: { label: string; value: number }) {
); } + +function previewBody(m: SqsPeekedMessage): string { + const max = 96; + if (m.body.length <= max) return m.body || "(empty)"; + return m.body.slice(0, max) + "…"; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} From 97ad686ab31e28d130f68f152686cb75061296c1 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Fri, 22 May 2026 01:37:44 +0900 Subject: [PATCH 2/5] web/admin: address r1 review feedback on Phase 5 SPA Six items called out across Codex P2, Gemini medium x2, and Claude bot on PR #798: 1. Codex P2 / Gemini medium / Bot bug #3 - clipboard write_text fires in non-secure contexts. Add capability check (navigator.clipboard?.writeText) and a try/catch around the await; on failure fall back to window.prompt with the JSON pre-selected so the operator can Ctrl/Cmd+C it manually. Surface the failure in a text-danger line so the action no longer silently fails. 2. Gemini medium - formatBytes used base 1024 but labelled kB/MB. Use KiB/MiB. (kB/MB are decimal SI units; the code multiplies by 1024.) 3. Bot accuracy #4 - the truncation display used message.body.length which is JavaScript's UTF-16 code-unit count, not bytes. The server applies BodyMaxBytes against raw UTF-8 so non-ASCII bodies (CJK, emoji) under-reported. Add utf8ByteLength(s) using TextEncoder and use it in the display. 4. Bot bug #2 - purgeRetryAfter was hardcoded to 60 regardless of the actual remaining cooldown. formatApiError already includes the server's "only one PurgeQueue per 60 seconds" message, so removed the state entirely. 5. Bot bug #1 - setLoading(false) in `finally` ran even when `catch` returned early on AbortError. Move setLoading out of finally into the explicit success and non-abort error branches so unmounted components don't get the state update. 6. Bot minor #6 - distilled the multi-line comment blocks on writePurgeInProgress, SqsPeekedAttribute, SqsPeekedMessage, SqsPeekResult, peekQueue, and purgeQueue to single-line summaries per CLAUDE.md conventions. Caller audit (semantic-change rule): no signature changes; the clipboard / formatBytes / utf8ByteLength helpers are private to SqsDetail.tsx; purgeRetryAfter removal touches only the same file. Verification: - cd web/admin && npm run lint passes - go test -race ./internal/admin/... passes - golangci-lint run ./internal/admin/... 0 issues Rebased onto main (Phase 4 PR #797 merged). --- internal/admin/sqs_handler.go | 14 +---- web/admin/src/api/client.ts | 30 +++------- web/admin/src/pages/SqsDetail.tsx | 94 +++++++++++++++++++------------ 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/internal/admin/sqs_handler.go b/internal/admin/sqs_handler.go index bd0ba39c..4beac1d3 100644 --- a/internal/admin/sqs_handler.go +++ b/internal/admin/sqs_handler.go @@ -698,17 +698,9 @@ func writeQueuesError(w http.ResponseWriter, err error, logger *slog.Logger, r * } } -// writePurgeInProgress emits the 429 response shape the design doc -// §3.4 specifies: Retry-After header (rounded up to whole seconds so -// a client retrying exactly at the deadline is guaranteed to clear) -// + JSON body { error, message, retry_after_seconds }. -// -// The `error` key (not `code`) matches writeJSONError's envelope so -// the SPA's apiFetch wrapper extracts the AWS-style sentinel into -// ApiError.code consistently with every other 4xx the admin surface -// returns. The retry_after_seconds field is in addition to the -// canonical Retry-After header so the SPA does not have to plumb -// response headers through its error path. +// writePurgeInProgress emits the 429 wire shape (Retry-After header +// + JSON body { error, message, retry_after_seconds }). Whole-second +// rounding-up so a deadline-exact retry is guaranteed to clear. func writePurgeInProgress(w http.ResponseWriter, err *PurgeInProgressError) { secs := int64(err.RetryAfter / time.Second) if err.RetryAfter%time.Second != 0 { diff --git a/web/admin/src/api/client.ts b/web/admin/src/api/client.ts index c0515c87..865b5d01 100644 --- a/web/admin/src/api/client.ts +++ b/web/admin/src/api/client.ts @@ -224,12 +224,8 @@ export interface SqsQueueList { queues: string[]; } -// SqsPeekedAttribute mirrors the typed MessageAttribute shape AWS -// SQS uses. data_type is "String" / "Number" / "Binary" / -// "String.MyCustom" etc.; exactly one of string_value or -// binary_value carries the payload (binary_value is base64 on the -// wire — keep as string here and decode lazily if a preview needs -// raw bytes). +// SqsPeekedAttribute mirrors AWS's typed MessageAttribute shape; +// binary_value arrives base64-encoded. export interface SqsPeekedAttribute { data_type: string; string_value?: string; @@ -239,10 +235,6 @@ export interface SqsPeekedAttribute { export interface SqsPeekedMessage { message_id: string; body: string; - // body_truncated is true when the wire body was cut to - // body_max_bytes on the server side. body_original_size carries - // the full byte length so the modal can render "showing N of M - // bytes". body_truncated: boolean; body_original_size: number; sent_timestamp: string; @@ -254,9 +246,7 @@ export interface SqsPeekedMessage { export interface SqsPeekResult { messages: SqsPeekedMessage[]; - // next_cursor is omitted when the queue is fully drained for the - // current MVCC snapshot. The client treats absence as "no further - // pages" and re-enables the page=1 navigation. + // Omitted when the walk has fully completed for this MVCC snapshot. next_cursor?: string; } @@ -361,12 +351,8 @@ export const api = { apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { signal }), deleteQueue: (name: string) => apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { method: "DELETE" }), - // peekQueue performs a non-destructive sample of currently-visible - // messages. Cursor pagination: pass back the prior call's - // next_cursor on subsequent pages, empty on the first page. The - // server clamps limit to [1, 100] (default 20) and body_max_bytes - // to [256, 262144] (default 4096); pass undefined to use the - // documented defaults. + // Non-destructive peek of currently-visible messages. Server clamps + // limit to [1, 100] and body_max_bytes to [256, 262144]. peekQueue: (name: string, opts?: SqsPeekOptions, signal?: AbortSignal) => apiFetch(`/sqs/queues/${encodeURIComponent(name)}/messages`, { query: { @@ -376,10 +362,8 @@ export const api = { }, signal, }), - // purgeQueue drains the queue's messages while leaving the queue - // itself (ARN, RedrivePolicy, tags, attributes) intact. AWS-shape - // 60-second rate limit per queue: a second purge inside the window - // returns 429 + retry_after_seconds in the body. + // Drains the queue's messages while leaving meta/ARN/RedrivePolicy intact. + // 60-second rate limit per queue: second purge inside the window → 429. purgeQueue: (name: string) => apiFetch(`/sqs/queues/${encodeURIComponent(name)}/messages`, { method: "DELETE" }), keyVizMatrix: (params: KeyVizParams, signal?: AbortSignal) => diff --git a/web/admin/src/pages/SqsDetail.tsx b/web/admin/src/pages/SqsDetail.tsx index dd621902..9592b18d 100644 --- a/web/admin/src/pages/SqsDetail.tsx +++ b/web/admin/src/pages/SqsDetail.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { - ApiError, api, type SqsPeekResult, type SqsPeekedMessage, @@ -208,7 +207,6 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess const [purgeName, setPurgeName] = useState(""); const [purging, setPurging] = useState(false); const [purgeError, setPurgeError] = useState(null); - const [purgeRetryAfter, setPurgeRetryAfter] = useState(null); const fetchPage = useCallback( async (pageCursor: string | undefined, signal?: AbortSignal) => { @@ -222,12 +220,13 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess ); setResult(res); setCursor(pageCursor); + setLoading(false); } catch (err) { - // Ignore aborts triggered by the cleanup function below; the - // component is unmounting and a state update would warn. + // Ignore aborts (component unmount). setLoading is NOT in + // finally because that would clear loading on unmounted + // component after abort. if (err instanceof DOMException && err.name === "AbortError") return; setError(formatApiError(err)); - } finally { setLoading(false); } }, @@ -247,7 +246,6 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess } setPurging(true); setPurgeError(null); - setPurgeRetryAfter(null); try { await api.purgeQueue(queue); setConfirmPurge(false); @@ -255,15 +253,13 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess onPurged(); void fetchPage(undefined); } catch (err) { - if (err instanceof ApiError && err.code === "PurgeQueueInProgress") { - // The 60-second cooldown is active. The server sends both a - // Retry-After header and a retry_after_seconds JSON field; - // ApiError currently only carries the parsed `code` and - // `message`, so the SPA shows the message text directly. A - // future apiFetch enhancement could surface the typed - // duration without changing the error model here. - setPurgeRetryAfter(60); - } + // Server includes the remaining cooldown in both the + // Retry-After header and the body's retry_after_seconds, but + // ApiError only carries code+message. formatApiError already + // includes the message ("only one PurgeQueue operation on each + // queue is allowed every 60 seconds") so showing it verbatim + // is sufficient; a future apiFetch enhancement could surface + // the typed duration if it becomes worth it. setPurgeError(formatApiError(err)); } finally { setPurging(false); @@ -354,14 +350,7 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess disabled={purging} autoFocus /> - {purgeError && ( -
- {purgeError} - {purgeRetryAfter !== null && ( - <> (retry after about {purgeRetryAfter} seconds) - )} -
- )} + {purgeError &&
{purgeError}
}
@@ -508,8 +516,13 @@ function MessageDetail({ message, queue }: MessageDetailProps) { )} + {copyError &&
{copyError}
}
-
@@ -534,6 +547,17 @@ function previewBody(m: SqsPeekedMessage): string { function formatBytes(n: number): string { if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`; - return `${(n / (1024 * 1024)).toFixed(1)} MB`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + return `${(n / (1024 * 1024)).toFixed(1)} MiB`; +} + +// utf8ByteLength counts the bytes a string occupies in UTF-8. The +// server applies BodyMaxBytes against raw UTF-8 bytes, so the +// "truncated to X of Y" display must use bytes, not JavaScript's +// UTF-16 string length (would underreport for CJK / emoji bodies). +function utf8ByteLength(s: string): number { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(s).byteLength; + } + return s.length; // legacy environment fallback } From 2397ca92f262cf42a1430f9a7c7c27c684a7e0f8 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Fri, 22 May 2026 01:45:21 +0900 Subject: [PATCH 3/5] web/admin: address r2 review (peek race + clipboard fallback + binary length) Three r2 items on PR #798: 1. Codex P2 (peek stale-response race): button-triggered peeks had no abort wiring, so a fast page-change could land the older response after the newer one and overwrite the Messages table with stale data. Add pendingAbortRef (AbortController per call): each fetchPage aborts the previous one and discards its result. 2. Bot bug (clipboard window.prompt fallback): the prompt was blocking and pre-empted setCopyError rendering, plus could truncate ~350KiB JSON in some browsers. Drop the prompt; point the operator at the body
 already rendered in the modal.

3. Bot accuracy (binary attribute length): displayed base64 char

   count instead of decoded bytes (~4/3 inflation). Add

   base64DecodedByteLength(b64) using padded-length arithmetic

   (no actual decode) and show ''.

Plus: shorten the IsDLQ / DLQSources docstrings on QueueSummary

per CLAUDE.md (the bot flagged them in the comment-style list).

Caller audit: no signature changes. pendingAbortRef and the new

base64DecodedByteLength helper are private to SqsDetail.tsx.
---
 internal/admin/sqs_handler.go     | 11 +----
 web/admin/src/pages/SqsDetail.tsx | 67 ++++++++++++++++++-------------
 2 files changed, 41 insertions(+), 37 deletions(-)

diff --git a/internal/admin/sqs_handler.go b/internal/admin/sqs_handler.go
index 4beac1d3..11ecf2c6 100644
--- a/internal/admin/sqs_handler.go
+++ b/internal/admin/sqs_handler.go
@@ -40,16 +40,9 @@ type QueueSummary struct {
 	CreatedAt  *time.Time        `json:"created_at,omitempty"`
 	Attributes map[string]string `json:"attributes,omitempty"`
 	Counters   QueueCounters     `json:"counters"`
-	// IsDLQ is true when at least one other queue's RedrivePolicy
-	// resolves its deadLetterTargetArn to this queue. The SPA uses
-	// the flag to switch the Messages-tab framing and the Purge
-	// button label between "Purge messages" and "Purge DLQ".
+	// True when another queue's RedrivePolicy points at this one.
 	IsDLQ bool `json:"is_dlq"`
-	// DLQSources lists the names of queues whose RedrivePolicy
-	// points at this queue, sorted lexicographically. Empty when
-	// IsDLQ is false; the SPA renders these as chips on the queue
-	// detail page so the operator confirms what feeds the DLQ
-	// before purging.
+	// Source queue names that point at this DLQ, sorted lex.
 	DLQSources []string `json:"dlq_sources,omitempty"`
 }
 
diff --git a/web/admin/src/pages/SqsDetail.tsx b/web/admin/src/pages/SqsDetail.tsx
index 9592b18d..ae81ceff 100644
--- a/web/admin/src/pages/SqsDetail.tsx
+++ b/web/admin/src/pages/SqsDetail.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { Link, useNavigate, useParams } from "react-router-dom";
 import {
   api,
@@ -207,24 +207,32 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess
   const [purgeName, setPurgeName] = useState("");
   const [purging, setPurging] = useState(false);
   const [purgeError, setPurgeError] = useState(null);
+  // pendingAbortRef holds the active AbortController so a fresh
+  // peek call can cancel the previous one. Without this, two
+  // button-triggered peeks racing on the wire could land out of
+  // order and the older response would overwrite the newer state
+  // (Codex r2 P2).
+  const pendingAbortRef = useRef(null);
 
   const fetchPage = useCallback(
-    async (pageCursor: string | undefined, signal?: AbortSignal) => {
+    async (pageCursor: string | undefined) => {
+      pendingAbortRef.current?.abort();
+      const ctl = new AbortController();
+      pendingAbortRef.current = ctl;
       setLoading(true);
       setError(null);
       try {
         const res = await api.peekQueue(
           queue,
           { limit: kPeekPageSize, cursor: pageCursor, body_max_bytes: kPeekBodyMaxBytes },
-          signal,
+          ctl.signal,
         );
+        if (ctl.signal.aborted) return;
         setResult(res);
         setCursor(pageCursor);
         setLoading(false);
       } catch (err) {
-        // Ignore aborts (component unmount). setLoading is NOT in
-        // finally because that would clear loading on unmounted
-        // component after abort.
+        if (ctl.signal.aborted) return;
         if (err instanceof DOMException && err.name === "AbortError") return;
         setError(formatApiError(err));
         setLoading(false);
@@ -234,9 +242,8 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess
   );
 
   useEffect(() => {
-    const ctl = new AbortController();
-    void fetchPage(undefined, ctl.signal);
-    return () => ctl.abort();
+    void fetchPage(undefined);
+    return () => pendingAbortRef.current?.abort();
   }, [fetchPage]);
 
   const onPurgeSubmit = async () => {
@@ -438,10 +445,10 @@ function MessageDetail({ message, queue }: MessageDetailProps) {
       message,
     };
     const json = JSON.stringify(payload, null, 2);
-    // navigator.clipboard is only available in secure contexts
-    // (HTTPS / localhost) and some self-hosted admin UIs run over
-    // plain HTTP. Capability check + fallback prompt keeps the
-    // modal usable in all environments. Codex r1 P2 + Gemini.
+    // navigator.clipboard is secure-context-only. On insecure /
+    // unavailable, point the operator at the body 
 in the
+    // modal rather than window.prompt (blocking modal pre-empts the
+    // error message and some browsers truncate ~350KiB payloads).
     if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
       try {
         await navigator.clipboard.writeText(json);
@@ -450,17 +457,11 @@ function MessageDetail({ message, queue }: MessageDetailProps) {
         setTimeout(() => setCopied(false), 1500);
         return;
       } catch (err) {
-        setCopyError(`Copy failed: ${String(err)}. Use the prompt to copy manually.`);
+        setCopyError(`Copy failed: ${String(err)}. Select the body text above to copy manually.`);
+        return;
       }
-    } else {
-      setCopyError("Clipboard API unavailable (insecure context). Use the prompt to copy manually.");
-    }
-    // Fallback: open a prompt with the payload pre-selected so the
-    // operator can copy with Ctrl/Cmd+C. window.prompt is universally
-    // supported and works in non-secure contexts.
-    if (typeof window !== "undefined") {
-      window.prompt("Copy the JSON payload:", json);
     }
+    setCopyError("Clipboard API unavailable (insecure context). Select the body text above to copy manually.");
   };
 
   return (
@@ -509,7 +510,7 @@ function MessageDetail({ message, queue }: MessageDetailProps) {
                 
{k}
{v.data_type}
- {v.string_value ?? (v.binary_value ? `` : "")} + {v.string_value ?? (v.binary_value ? `` : "")}
))} @@ -551,13 +552,23 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MiB`; } -// utf8ByteLength counts the bytes a string occupies in UTF-8. The -// server applies BodyMaxBytes against raw UTF-8 bytes, so the -// "truncated to X of Y" display must use bytes, not JavaScript's -// UTF-16 string length (would underreport for CJK / emoji bodies). +// utf8ByteLength: server applies BodyMaxBytes to UTF-8 bytes, not +// UTF-16 code units; CJK/emoji bodies would otherwise under-report. function utf8ByteLength(s: string): number { if (typeof TextEncoder !== "undefined") { return new TextEncoder().encode(s).byteLength; } - return s.length; // legacy environment fallback + return s.length; +} + +// base64DecodedByteLength returns the decoded length of a base64 +// string without doing the actual decode (cheap arithmetic on the +// padded length). Each 4 base64 chars decode to 3 bytes, minus the +// padding count. +function base64DecodedByteLength(b64: string): number { + if (b64.length === 0) return 0; + let padding = 0; + if (b64.endsWith("==")) padding = 2; + else if (b64.endsWith("=")) padding = 1; + return Math.floor(b64.length * 3 / 4) - padding; } From 3a023a1b60a617059af8d3aa8c16fbbf9e186d64 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Fri, 22 May 2026 01:53:49 +0900 Subject: [PATCH 4/5] web/admin: condense remaining multi-line comments to one-liners Claude r3 noted that the r2 commit left three multi-line comment blocks behind (and added two new ones) despite claiming to address the CLAUDE.md style rule. Squash-cleanup pass: - pendingAbortRef: 4 lines to 1 - base64DecodedByteLength: 4 lines to 1 - is_dlq / dlq_sources in client.ts SqsQueueSummary: 4 + 3 to 1 + 1 No behaviour change. Verdict from the bot was 'PR is ready to merge'; this is the style follow-up. --- web/admin/src/api/client.ts | 8 ++------ web/admin/src/pages/SqsDetail.tsx | 11 ++--------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/web/admin/src/api/client.ts b/web/admin/src/api/client.ts index 865b5d01..4c248437 100644 --- a/web/admin/src/api/client.ts +++ b/web/admin/src/api/client.ts @@ -210,13 +210,9 @@ export interface SqsQueueSummary { created_at?: string; attributes?: Record; counters: SqsQueueCounters; - // is_dlq is true when another queue's RedrivePolicy resolves its - // deadLetterTargetArn to this queue. Drives the Messages-tab - // framing and the Purge button label ("Purge messages" vs - // "Purge DLQ"). + // True when another queue's RedrivePolicy points at this one. is_dlq: boolean; - // dlq_sources lists the source queues' names that point at this - // queue, in lexicographic order. Empty when is_dlq is false. + // Source queue names that point at this DLQ, sorted lex. dlq_sources?: string[]; } diff --git a/web/admin/src/pages/SqsDetail.tsx b/web/admin/src/pages/SqsDetail.tsx index ae81ceff..93c1a984 100644 --- a/web/admin/src/pages/SqsDetail.tsx +++ b/web/admin/src/pages/SqsDetail.tsx @@ -207,11 +207,7 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess const [purgeName, setPurgeName] = useState(""); const [purging, setPurging] = useState(false); const [purgeError, setPurgeError] = useState(null); - // pendingAbortRef holds the active AbortController so a fresh - // peek call can cancel the previous one. Without this, two - // button-triggered peeks racing on the wire could land out of - // order and the older response would overwrite the newer state - // (Codex r2 P2). + // Cancels the prior peek so a slow response cannot overwrite newer state. const pendingAbortRef = useRef(null); const fetchPage = useCallback( @@ -561,10 +557,7 @@ function utf8ByteLength(s: string): number { return s.length; } -// base64DecodedByteLength returns the decoded length of a base64 -// string without doing the actual decode (cheap arithmetic on the -// padded length). Each 4 base64 chars decode to 3 bytes, minus the -// padding count. +// Decoded length without actually decoding: 4 chars → 3 bytes, minus padding. function base64DecodedByteLength(b64: string): number { if (b64.length === 0) return 0; let padding = 0; From 0370c0c1f4bdd9f7bfc3955e09b29bab3662d685 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Fri, 22 May 2026 02:02:58 +0900 Subject: [PATCH 5/5] web/admin: clear stale peek result on queue change and on fetch failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex r3 P2 on PR #798: when the peek request rejects, the catch block set error but left result intact. Combined with the queue param change re-running fetchPage, a failed fetch on the new queue could leave the prior queue's messages visible — misleading during troubleshooting / purge decisions. Two fixes: 1. useEffect now clears result + cursor when fetchPage changes (i.e., queue prop changed), so the brief loading window does not show cross-queue data. 2. catch path clears result + cursor before setting the error, so a failed refresh after a queue change does not leave the prior queue's table visible. --- web/admin/src/pages/SqsDetail.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/admin/src/pages/SqsDetail.tsx b/web/admin/src/pages/SqsDetail.tsx index 93c1a984..73bdb56a 100644 --- a/web/admin/src/pages/SqsDetail.tsx +++ b/web/admin/src/pages/SqsDetail.tsx @@ -230,6 +230,10 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess } catch (err) { if (ctl.signal.aborted) return; if (err instanceof DOMException && err.name === "AbortError") return; + // Clear stale rows so a failed fetch after a queue change does + // not leave the prior queue's messages visible (Codex r3 P2). + setResult(null); + setCursor(undefined); setError(formatApiError(err)); setLoading(false); } @@ -238,6 +242,10 @@ function MessagesSection({ queue, isFifo, isDLQ, inFlightCount, onPurged }: Mess ); useEffect(() => { + // Clear prior queue's table when the queue prop changes so the + // brief loading window does not show cross-queue data. + setResult(null); + setCursor(undefined); void fetchPage(undefined); return () => pendingAbortRef.current?.abort(); }, [fetchPage]);