93 lines
11 KiB
TypeScript
93 lines
11 KiB
TypeScript
import {useEffect, useState} from 'react'
|
|
import {Loader2, Send, Trash2} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {deleteSqsMessage, listCloudResources, purgeSqsQueue, receiveSqsMessages, sendSqsMessage, startSqsMoveTask, updateCloudResource} from '@/api/cloudProxyClient'
|
|
import type {CloudResource, SqsMessageAttribute} from '@/types/resource'
|
|
|
|
export function SqsQueuePanel({resource, runtimeReachable}: {resource?: CloudResource; runtimeReachable: boolean}) {
|
|
const [body, setBody] = useState('')
|
|
const [attributes, setAttributes] = useState('{}')
|
|
const [groupId, setGroupId] = useState('')
|
|
const [deduplicationId, setDeduplicationId] = useState('')
|
|
const [messages, setMessages] = useState<Awaited<ReturnType<typeof receiveSqsMessages>>>([])
|
|
const [purgeConfirm, setPurgeConfirm] = useState(false)
|
|
const [dlqArn, setDlqArn] = useState('')
|
|
const [maxReceiveCount, setMaxReceiveCount] = useState('2')
|
|
const [moveDestination, setMoveDestination] = useState('')
|
|
const queryClient = useQueryClient()
|
|
const queuesQuery = useQuery({queryKey: ['cloud-resources', 'aws', 'messaging'], queryFn: ({signal}) => listCloudResources('aws', 'messaging', undefined, signal), enabled: Boolean(resource) && runtimeReachable})
|
|
const isQueue = resource?.service === 'messaging' && (resource.type === 'queue' || resource.type === 'fifo-queue')
|
|
const isFifo = resource?.type === 'fifo-queue'
|
|
|
|
useEffect(() => { setBody(''); setAttributes('{}'); setGroupId(''); setDeduplicationId(''); setMessages([]); setPurgeConfirm(false); setDlqArn(''); setMoveDestination('') }, [resource?.id])
|
|
|
|
const send = useMutation({mutationFn: () => sendSqsMessage('aws', resource!.id, {messageBody: body, messageAttributes: parseAttributes(attributes), ...(isFifo ? {messageGroupId: groupId, ...(deduplicationId ? {messageDeduplicationId: deduplicationId} : {})} : {})}), onSuccess: () => { setBody(''); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
|
const receive = useMutation({mutationFn: () => receiveSqsMessages('aws', resource!.id, 10), onSuccess: (received) => { setMessages(received); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
|
const remove = useMutation({mutationFn: (message: Awaited<ReturnType<typeof receiveSqsMessages>>[number]) => deleteSqsMessage('aws', resource!.id, message.receiptHandle), onSuccess: (_, message) => { setMessages((current) => current.filter((item) => item.receiptHandle !== message.receiptHandle)); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
|
const purge = useMutation({mutationFn: () => purgeSqsQueue('aws', resource!.id), onSuccess: () => { setMessages([]); setPurgeConfirm(false); void queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})}})
|
|
const configureDlq = useMutation({mutationFn: () => updateCloudResource('aws', 'messaging', resource!.id, {redrivePolicy: {deadLetterTargetArn: dlqArn, maxReceiveCount}}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'messaging']})})
|
|
const move = useMutation({mutationFn: () => startSqsMoveTask('aws', resource!.id, moveDestination || undefined)})
|
|
|
|
if (!isQueue) return null
|
|
const queues = (queuesQuery.data ?? []).filter((queue) => queue.id !== resource.id && queue.type === resource.type && typeof queue.metadata.arn === 'string')
|
|
const error = send.error ?? receive.error ?? remove.error ?? purge.error ?? configureDlq.error ?? move.error
|
|
return <section className="table-panel sqs-queue-panel">
|
|
<div className="dynamic-stage-header"><div><p className="eyebrow">SQS Messages</p><h3>Send and receive</h3><p className="muted compact-text">Polling receives messages and starts their configured visibility timeout. Messages are deleted only when you explicitly delete them.</p></div></div>
|
|
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (body && (!isFifo || groupId)) send.mutate() }}>
|
|
<label className="dynamic-field dynamic-field--span"><span>Message body</span><textarea className="textarea code-textarea" value={body} onChange={(event) => setBody(event.target.value)} required/></label>
|
|
<label className="dynamic-field dynamic-field--span"><span>Message attributes (JSON)</span><textarea className="textarea code-textarea small" value={attributes} onChange={(event) => setAttributes(event.target.value)} placeholder={'{"kind":{"dataType":"String","stringValue":"order"}}'}/><small>Supports String, Number, and Binary attributes. Binary values are base64.</small></label>
|
|
{isFifo && <><label className="dynamic-field"><span>Message group ID</span><input className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)} required/></label><label className="dynamic-field"><span>Deduplication ID (optional)</span><input className="input" value={deduplicationId} onChange={(event) => setDeduplicationId(event.target.value)}/></label></>}
|
|
<button className="button primary" type="submit" disabled={!runtimeReachable || !body || (isFifo && !groupId) || send.isPending}>{send.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send message'}</button>
|
|
</form>
|
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Receive</p><h3>Received messages</h3></div><button className="button" type="button" disabled={!runtimeReachable || receive.isPending} onClick={() => receive.mutate()}>{receive.isPending ? 'Polling' : 'Poll for messages'}</button></div>
|
|
{messages.length === 0 && <p className="muted compact-text">Poll to receive up to 10 messages. Leaving this panel or waiting for the visibility timeout does not delete them.</p>}
|
|
{messages.map((message) => <article className="apigateway-route-item sqs-received-message" key={message.receiptHandle}><div className="apigateway-route-select"><code>{message.messageId}</code><SqsMessageBody body={message.body}/><small>Receive count: {message.attributes.ApproximateReceiveCount ?? '-'} · Visibility timeout: {String(resource.metadata.visibilityTimeout ?? 30)}s</small><details><summary>Message details</summary><pre className="sqs-message-body">{JSON.stringify({attributes: message.attributes, messageAttributes: message.messageAttributes, receiptHandle: message.receiptHandle}, null, 2)}</pre></details></div><button className="icon-btn danger" type="button" aria-label={`Delete message ${message.messageId}`} disabled={remove.isPending} onClick={() => remove.mutate(message)}><Trash2 size={14}/></button></article>)}
|
|
<section className="apigateway-route-explorer"><p className="eyebrow">Queue management</p>{!purgeConfirm ? <button className="button danger" type="button" onClick={() => setPurgeConfirm(true)}>Purge queue</button> : <div className="apigateway-route-confirm"><span>Purge approximately {String(resource.metadata.approximateMessages ?? 0)} visible messages?</span><button className="button danger compact" type="button" disabled={purge.isPending} onClick={() => purge.mutate()}>Confirm purge</button><button className="button compact" type="button" onClick={() => setPurgeConfirm(false)}>Cancel</button></div>}</section>
|
|
<section className="apigateway-route-explorer"><p className="eyebrow">Experimental: dead-letter queues</p><p className="muted compact-text">DLQ redrive threshold behavior is not fully AWS-compatible in this runtime. Use only for local experiments.</p><div className="dynamic-form"><label className="dynamic-field"><span>Target DLQ</span><select className="input" value={dlqArn} onChange={(event) => setDlqArn(event.target.value)}><option value="">Select a matching queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><label className="dynamic-field"><span>Max receive count</span><input className="input" type="number" min="1" max="1000" value={maxReceiveCount} onChange={(event) => setMaxReceiveCount(event.target.value)}/></label><button className="button" type="button" disabled={!dlqArn || configureDlq.isPending} onClick={() => configureDlq.mutate()}>Save experimental DLQ</button></div><div className="dynamic-form"><label className="dynamic-field"><span>Move destination</span><select className="input" value={moveDestination} onChange={(event) => setMoveDestination(event.target.value)}><option value="">Original source queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><button className="button" type="button" disabled={move.isPending} onClick={() => move.mutate()}>Move DLQ messages</button>{move.data && <code>{move.data.taskHandle}</code>}</div></section>
|
|
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
|
</section>
|
|
}
|
|
|
|
function SqsMessageBody({body}: {body: string}) {
|
|
const notification = snsNotification(body)
|
|
if (!notification) return <pre className="sqs-message-body">{body}</pre>
|
|
return <div className="sqs-sns-notification"><p className="metric-label">SNS notification envelope</p><JsonCode value={notification.envelope}/><p className="metric-label">SNS message payload</p>{notification.payload === null ? <pre className="sqs-message-body">{notification.message}</pre> : <JsonCode value={notification.payload}/>}</div>
|
|
}
|
|
|
|
function snsNotification(body: string): {envelope: Record<string, unknown>; message: string; payload: unknown | null} | null {
|
|
try {
|
|
const parsed = JSON.parse(body) as Record<string, unknown>
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.Type !== 'Notification' || typeof parsed.Message !== 'string') return null
|
|
const {Message, ...envelope} = parsed
|
|
try {
|
|
return {envelope, message: Message, payload: JSON.parse(Message)}
|
|
} catch {
|
|
return {envelope, message: Message, payload: null}
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function JsonCode({value}: {value: unknown}) {
|
|
const text = JSON.stringify(value, null, 2)
|
|
const tokens: Array<{value: string; kind?: string}> = []
|
|
const expression = /("(?:\\.|[^"\\])*")(?=\s*:)|(\btrue\b|\bfalse\b|\bnull\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|("(?:\\.|[^"\\])*")/g
|
|
let cursor = 0
|
|
for (const match of text.matchAll(expression)) {
|
|
const index = match.index ?? 0
|
|
if (index > cursor) tokens.push({value: text.slice(cursor, index)})
|
|
tokens.push({value: match[0], kind: match[1] ? 'key' : match[2] ? 'literal' : match[3] ? 'number' : 'string'})
|
|
cursor = index + match[0].length
|
|
}
|
|
if (cursor < text.length) tokens.push({value: text.slice(cursor)})
|
|
return <pre className="sqs-message-body sqs-json-code">{tokens.map((token, index) => <span className={token.kind ? `json-${token.kind}` : undefined} key={index}>{token.value}</span>)}</pre>
|
|
}
|
|
|
|
function parseAttributes(value: string): Record<string, SqsMessageAttribute> | undefined {
|
|
if (!value.trim()) return undefined
|
|
const parsed = JSON.parse(value) as Record<string, SqsMessageAttribute>
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Message attributes must be a JSON object.')
|
|
return parsed
|
|
}
|