Files
floci-ui/packages/frontend/src/components/SqsQueuePanel.tsx
T
2026-09-30 17:43:42 -04:00

57 lines
9.1 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" key={message.receiptHandle}><div className="apigateway-route-select"><code>{message.messageId}</code><pre>{message.body}</pre><small>Receive count: {message.attributes.ApproximateReceiveCount ?? '-'} · Visibility timeout: {String(resource.metadata.visibilityTimeout ?? 30)}s</small><details><summary>Message details</summary><pre>{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 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
}