feat: MSK, MSK CONNECT and SES
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {Loader2, Plus, RefreshCw, Send, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createMskCluster, createMskTopic, deleteMskCluster, deleteMskTopic, getMskConsumerGroupLag, listMskClusters, listMskTopics, peekMskMessages, produceMskMessage} from "@/api/mskClient";
|
||||
import {listEc2Subnets} from "@/api/aws/ec2.api";
|
||||
|
||||
export function MskPanel({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [clusterArn, setClusterArn] = useState("");
|
||||
const [deleteClusterConfirmation, setDeleteClusterConfirmation] = useState(false);
|
||||
const [clusterName, setClusterName] = useState("");
|
||||
const [brokers, setBrokers] = useState("1");
|
||||
const [version, setVersion] = useState("3.7.x");
|
||||
const [subnets, setSubnets] = useState<string[]>([]);
|
||||
const [topic, setTopic] = useState("");
|
||||
const [partitions, setPartitions] = useState("1");
|
||||
const [retentionMs, setRetentionMs] = useState("");
|
||||
const [messageTopic, setMessageTopic] = useState("");
|
||||
const [messageKey, setMessageKey] = useState("");
|
||||
const [messageValue, setMessageValue] = useState("");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [messages, setMessages] = useState<Awaited<ReturnType<typeof peekMskMessages>>>([]);
|
||||
const [lag, setLag] = useState<Awaited<ReturnType<typeof getMskConsumerGroupLag>>>([]);
|
||||
const clustersQuery = useQuery({queryKey: ["msk-clusters"], queryFn: ({signal}) => listMskClusters(signal), enabled: runtimeReachable});
|
||||
const subnetsQuery = useQuery({queryKey: ["ec2", "subnets"], queryFn: ({signal}) => listEc2Subnets(undefined, signal), enabled: runtimeReachable});
|
||||
const topicsQuery = useQuery({queryKey: ["msk-topics", clusterArn], queryFn: ({signal}) => listMskTopics(clusterArn, signal), enabled: runtimeReachable && Boolean(clusterArn)});
|
||||
|
||||
useEffect(() => { const firstArn = clustersQuery.data?.[0]?.ClusterArn; if (!clusterArn && firstArn) setClusterArn(firstArn); }, [clusterArn, clustersQuery.data]);
|
||||
useEffect(() => { setMessageTopic(""); setMessages([]); setLag([]); }, [clusterArn]);
|
||||
|
||||
const createCluster = useMutation({mutationFn: () => createMskCluster({clusterName: clusterName.trim(), kafkaVersion: version.trim(), numberOfBrokerNodes: Number(brokers), brokerNodeGroupInfo: {clientSubnets: subnets}}), onSuccess: () => { setClusterName(""); void queryClient.invalidateQueries({queryKey: ["msk-clusters"]}); }});
|
||||
const deleteCluster = useMutation({mutationFn: () => deleteMskCluster(clusterArn), onSuccess: () => { setClusterArn(""); setDeleteClusterConfirmation(false); void queryClient.invalidateQueries({queryKey: ["msk-clusters"]}); }});
|
||||
const createTopic = useMutation({mutationFn: () => createMskTopic(clusterArn, {topic: topic.trim(), numPartitions: Number(partitions), ...(retentionMs.trim() ? {configEntries: [{name: "retention.ms", value: retentionMs.trim()}]} : {})}), onSuccess: () => { setTopic(""); setRetentionMs(""); void queryClient.invalidateQueries({queryKey: ["msk-topics", clusterArn]}); }});
|
||||
const deleteTopic = useMutation({mutationFn: (topicName: string) => deleteMskTopic(clusterArn, topicName), onSuccess: () => { setMessages([]); void queryClient.invalidateQueries({queryKey: ["msk-topics", clusterArn]}); }});
|
||||
const produce = useMutation({mutationFn: () => produceMskMessage(clusterArn, messageTopic, {value: messageValue, ...(messageKey.trim() ? {key: messageKey} : {})}), onSuccess: () => { setMessageKey(""); setMessageValue(""); }});
|
||||
const peek = useMutation({mutationFn: () => peekMskMessages(clusterArn, messageTopic, 50), onSuccess: setMessages});
|
||||
const loadLag = useMutation({mutationFn: () => getMskConsumerGroupLag(clusterArn, groupId.trim(), messageTopic || undefined), onSuccess: setLag});
|
||||
const error = createCluster.error ?? deleteCluster.error ?? createTopic.error ?? deleteTopic.error ?? produce.error ?? peek.error ?? loadLag.error;
|
||||
|
||||
return <div className="sns-topic-workspace msk-workspace">
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Amazon MSK</p><h3>Kafka clusters</h3><p className="muted compact-text">Broker access is open: no IAM, SASL, or TLS authentication is configured.</p></div><button className="button" type="button" disabled={!runtimeReachable || clustersQuery.isFetching} onClick={() => clustersQuery.refetch()}><RefreshCw size={13}/>{clustersQuery.isFetching ? "Loading" : "Refresh"}</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}>
|
||||
<label className="dynamic-field"><span>Cluster name</span><input className="input" value={clusterName} onChange={(event) => setClusterName(event.target.value)} required /></label><label className="dynamic-field"><span>Broker nodes</span><input className="input" type="number" min="1" value={brokers} onChange={(event) => setBrokers(event.target.value)} required /></label><label className="dynamic-field"><span>Kafka version</span><input className="input" value={version} onChange={(event) => setVersion(event.target.value)} required /></label><label className="dynamic-field"><span>Client subnets</span><select className="input" multiple value={subnets} onChange={(event) => setSubnets(Array.from(event.currentTarget.selectedOptions, (option) => option.value))} required>{subnetsQuery.data?.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.subnetId} ({subnet.availabilityZone})</option>)}</select><small>Select one or more existing EC2 subnets.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || subnets.length === 0 || createCluster.isPending}>{createCluster.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/>}{createCluster.isPending ? "Creating" : "Create cluster"}</button>
|
||||
</form>
|
||||
<div className="apigateway-route-explorer"><label className="dynamic-field"><span>Active cluster</span><select className="input" value={clusterArn} onChange={(event) => { setClusterArn(event.target.value); setDeleteClusterConfirmation(false); }} disabled={!runtimeReachable}><option value="">Select a cluster</option>{clustersQuery.data?.map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName ?? cluster.ClusterArn} {cluster.State ? `(${cluster.State})` : ""}</option>)}</select></label>{clusterArn && (deleteClusterConfirmation ? <div className="apigateway-route-confirm"><span>Delete this cluster and all topics?</span><button className="button danger compact" type="button" disabled={deleteCluster.isPending} onClick={() => deleteCluster.mutate()}>{deleteCluster.isPending ? "Deleting" : "Delete cluster"}</button><button className="button compact" type="button" disabled={deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(false)}>Cancel</button></div> : <button className="button danger compact" type="button" disabled={!runtimeReachable || deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(true)}><Trash2 size={13}/>Delete cluster</button>)}</div>
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Topics</p><h3>Kafka Admin API</h3><p className="muted compact-text">Topic management uses the cluster's Kafka Admin API. Retention configuration is accepted, but enforcement is unverified.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && topic.trim() && Number(partitions) > 0) createTopic.mutate(); }}><label className="dynamic-field"><span>Topic</span><input className="input" value={topic} onChange={(event) => setTopic(event.target.value)} required /></label><label className="dynamic-field"><span>Partitions</span><input className="input" type="number" min="1" value={partitions} onChange={(event) => setPartitions(event.target.value)} required /></label><label className="dynamic-field"><span>Retention (ms, optional)</span><input className="input" type="number" min="0" value={retentionMs} onChange={(event) => setRetentionMs(event.target.value)} /><small>Configured but enforcement unverified.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || createTopic.isPending}>{createTopic.isPending ? "Creating" : "Create topic"}</button></form>
|
||||
{topicsQuery.isLoading && <p className="muted compact-text">Loading topics...</p>}{topicsQuery.data?.length === 0 && <p className="muted compact-text">No topics found for this cluster.</p>}{topicsQuery.data?.map((item) => <div className="apigateway-route-item" key={item.name}><div className="apigateway-route-select"><code>{item.name}</code><small>{item.partitionCount} partition{item.partitionCount === 1 ? "" : "s"}</small></div><button className="icon-btn danger" type="button" aria-label={`Delete topic ${item.name}`} disabled={deleteTopic.isPending} onClick={() => deleteTopic.mutate(item.name)}><Trash2 size={14}/></button></div>)}
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Messages</p><h3>Produce and peek</h3><p className="muted compact-text">Peek uses a non-committing consumer and reads from the beginning.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && messageTopic && messageValue) produce.mutate(); }}><label className="dynamic-field"><span>Topic</span><select className="input" value={messageTopic} onChange={(event) => setMessageTopic(event.target.value)} required><option value="">Select a topic</option>{topicsQuery.data?.map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label><label className="dynamic-field"><span>Key (optional)</span><input className="input" value={messageKey} onChange={(event) => setMessageKey(event.target.value)} /></label><label className="dynamic-field dynamic-field--span"><span>Value</span><textarea className="textarea code-textarea small" value={messageValue} onChange={(event) => setMessageValue(event.target.value)} required /></label><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || !messageTopic || !messageValue || produce.isPending}>{produce.isPending ? <Loader2 size={13} className="spin"/> : <Send size={13}/>}{produce.isPending ? "Producing" : "Produce message"}</button><button className="button" type="button" disabled={!runtimeReachable || !clusterArn || !messageTopic || peek.isPending} onClick={() => peek.mutate()}>{peek.isPending ? "Peeking" : "Peek messages"}</button></form>
|
||||
{messages.map((message) => <article className="apigateway-route-item" key={`${message.partition}:${message.offset}`}><div className="apigateway-route-select"><pre>{message.value ?? ""}</pre><small>Partition {message.partition} · Offset {message.offset} · Timestamp {formatTimestamp(message.timestamp)}</small>{message.key && <small>Key: <code>{message.key}</code></small>}</div></article>)}{messages.length === 0 && <p className="muted compact-text">Select a topic and peek to inspect up to 50 messages.</p>}
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Consumer groups</p><h3>Lag</h3></div></div><form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && groupId.trim()) loadLag.mutate(); }}><label className="dynamic-field"><span>Consumer group ID</span><input className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)} required /></label><button className="button" type="submit" disabled={!runtimeReachable || !clusterArn || !groupId.trim() || loadLag.isPending}>{loadLag.isPending ? "Loading" : "View lag"}</button></form>{lag.map((topicLag) => <div className="apigateway-route-item" key={topicLag.topic}><div className="apigateway-route-select"><code>{topicLag.topic}</code><small>Total lag: {topicLag.totalLag}</small>{topicLag.partitions.map((partition) => <small key={partition.partition}>Partition {partition.partition}: {partition.currentOffset} / {partition.endOffset}, lag {partition.lag}</small>)}</div></div>)}{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string { const date = new Date(Number(timestamp)); return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString(); }
|
||||
Reference in New Issue
Block a user