Files
floci-ui/packages/frontend/src/components/MskConnectPanel.tsx
T
2026-10-05 11:06:57 -04:00

59 lines
14 KiB
TypeScript

import {useEffect, useState} from "react";
import {Link} from "react-router-dom";
import {Loader2, Plus, RefreshCw, Trash2} from "lucide-react";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {listCloudResources} from "@/api/cloudProxyClient";
import {getMskBootstrapBrokers, listMskClusters} from "@/api/mskClient";
import {createS3SinkConnector, deleteConnector, deleteCustomPlugin, describeConnector, listConnectors, listCustomPlugins, registerCustomPlugin, updateS3SinkConnector, type ConnectorDescription, type S3SinkConnectorInput} from "@/api/mskConnectClient";
const defaults: S3SinkConnectorInput = {
connectorName: "", bootstrapServers: "", kafkaConnectVersion: "3.7.x", serviceExecutionRoleArn: "", customPluginArn: "", customPluginRevision: 1,
workerCount: 1, mcuCount: 1, topics: "", keyConverter: "string", valueConverter: "json", s3Bucket: "", s3Region: "us-east-1", flushSize: "1", rotateIntervalMs: "60000", logGroup: "",
};
export function MskConnectPanel({runtimeReachable}: {runtimeReachable: boolean}) {
const queryClient = useQueryClient();
const [plugin, setPlugin] = useState({name: "", bucket: "", fileKey: "", objectVersion: ""});
const [form, setForm] = useState<S3SinkConnectorInput>(defaults);
const [selectedArn, setSelectedArn] = useState("");
const pluginsQuery = useQuery({queryKey: ["msk-connect-plugins"], queryFn: ({signal}) => listCustomPlugins(signal), enabled: runtimeReachable});
const clustersQuery = useQuery({queryKey: ["msk-connect-clusters"], queryFn: ({signal}) => listMskClusters(signal), enabled: runtimeReachable});
const connectorsQuery = useQuery({queryKey: ["msk-connect-connectors"], queryFn: ({signal}) => listConnectors(signal), enabled: runtimeReachable});
const bucketsQuery = useQuery({queryKey: ["cloud-resources", "aws", "storage", "msk-connect"], queryFn: ({signal}) => listCloudResources("aws", "storage", "", signal), enabled: runtimeReachable});
const connectorQuery = useQuery({queryKey: ["msk-connect-connector", selectedArn], queryFn: ({signal}) => describeConnector(selectedArn, signal), enabled: runtimeReachable && Boolean(selectedArn)});
const registerPlugin = useMutation({mutationFn: () => registerCustomPlugin(plugin), onSuccess: () => { setPlugin({name: "", bucket: "", fileKey: "", objectVersion: ""}); void queryClient.invalidateQueries({queryKey: ["msk-connect-plugins"]}); }});
const removePlugin = useMutation({mutationFn: (customPluginArn: string) => deleteCustomPlugin(customPluginArn), onSuccess: () => void queryClient.invalidateQueries({queryKey: ["msk-connect-plugins"]})});
const createConnector = useMutation({mutationFn: () => createS3SinkConnector(form), onSuccess: () => { setForm(defaults); void queryClient.invalidateQueries({queryKey: ["msk-connect-connectors"]}); }});
const updateConnector = useMutation({mutationFn: (connector: ConnectorDescription) => updateS3SinkConnector(selectedArn, {...form, currentVersion: connector.currentVersion ?? ""}), onSuccess: () => void queryClient.invalidateQueries({queryKey: ["msk-connect-connector", selectedArn]})});
const removeConnector = useMutation({mutationFn: () => deleteConnector(selectedArn), onSuccess: () => { setSelectedArn(""); void queryClient.invalidateQueries({queryKey: ["msk-connect-connectors"]}); }});
useEffect(() => { if (!selectedArn && connectorsQuery.data?.[0]?.connectorArn) setSelectedArn(connectorsQuery.data[0].connectorArn); }, [connectorsQuery.data, selectedArn]);
useEffect(() => {
const connector = connectorQuery.data;
if (!connector) return;
const configuration = connector.connectorConfiguration ?? {};
const capacity = connector.capacity?.provisionedCapacity;
setForm((value) => ({...value, workerCount: capacity?.workerCount ?? value.workerCount, mcuCount: capacity?.mcuCount ?? value.mcuCount, topics: configuration.topics ?? value.topics, s3Bucket: configuration["s3.bucket.name"] ?? value.s3Bucket, s3Region: configuration["s3.region"] ?? value.s3Region, flushSize: configuration["flush.size"] ?? value.flushSize, rotateIntervalMs: configuration["rotate.interval.ms"] ?? value.rotateIntervalMs, keyConverter: configuration["key.converter"]?.includes("Json") ? "json" : "string", valueConverter: configuration["value.converter"]?.includes("String") ? "string" : "json"}));
}, [connectorQuery.data]);
const set = <K extends keyof S3SinkConnectorInput>(key: K, value: S3SinkConnectorInput[K]) => setForm((current) => ({...current, [key]: value}));
const error = registerPlugin.error ?? removePlugin.error ?? createConnector.error ?? updateConnector.error ?? removeConnector.error;
const canCreate = form.connectorName.trim() && form.bootstrapServers.trim() && form.serviceExecutionRoleArn.trim() && form.customPluginArn && form.topics.trim() && form.s3Bucket;
return <div className="sns-topic-workspace msk-workspace">
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">MSK Connect</p><h3>Custom plugins</h3><p className="muted compact-text">Register plugin ZIP archives from S3 using the AWS CreateCustomPlugin shape.</p></div><Link className="button" to="/cloud-explorer/aws/storage">Browse S3</Link></div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (plugin.name && plugin.bucket && plugin.fileKey) registerPlugin.mutate(); }}><label className="dynamic-field"><span>Plugin name</span><input className="input" value={plugin.name} onChange={(event) => setPlugin({...plugin, name: event.target.value})} required/></label><BucketField label="Plugin S3 bucket" buckets={bucketsQuery.data?.map((bucket) => bucket.name) ?? []} value={plugin.bucket} onChange={(bucket) => setPlugin({...plugin, bucket})}/><label className="dynamic-field"><span>ZIP object key</span><input className="input" value={plugin.fileKey} onChange={(event) => setPlugin({...plugin, fileKey: event.target.value})} placeholder="plugins/s3-sink.zip" required/></label><label className="dynamic-field"><span>Object version (optional)</span><input className="input" value={plugin.objectVersion} onChange={(event) => setPlugin({...plugin, objectVersion: event.target.value})}/></label><button className="button primary" disabled={!runtimeReachable || registerPlugin.isPending} type="submit">{registerPlugin.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/> }Register plugin</button></form>
{pluginsQuery.data?.map((item) => <div className="apigateway-route-item" key={item.customPluginArn}><div className="apigateway-route-select"><code>{item.name}</code><small>Revision {item.revision ?? 1} {item.customPluginState ? `(${item.customPluginState})` : ""}</small></div><button className="button danger" type="button" disabled={!item.customPluginArn || removePlugin.isPending} onClick={() => { if (item.customPluginArn) removePlugin.mutate(item.customPluginArn); }}><Trash2 size={13}/>Delete plugin</button></div>)}
</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">S3 Sink</p><h3>Create connector</h3><p className="muted compact-text">Uses the Confluent S3 Sink connector configuration, not an editable raw JSON payload.</p></div><button className="button" type="button" onClick={() => connectorsQuery.refetch()} disabled={!runtimeReachable || connectorsQuery.isFetching}><RefreshCw size={13}/>Refresh</button></div>
<label className="dynamic-field"><span>MSK cluster</span><select className="input" defaultValue="" onChange={(event) => { const clusterArn = event.target.value; if (clusterArn) { void getMskBootstrapBrokers(clusterArn).then((bootstrapBrokers) => set("bootstrapServers", bootstrapBrokers)); } }}><option value="">Select a cluster</option>{clustersQuery.data?.filter((cluster) => cluster.State === "ACTIVE").map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName}</option>)}</select></label>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (canCreate) createConnector.mutate(); }}><label className="dynamic-field"><span>Connector name</span><input className="input" value={form.connectorName} onChange={(event) => set("connectorName", event.target.value)} required/></label><label className="dynamic-field"><span>Bootstrap servers</span><input className="input" value={form.bootstrapServers} onChange={(event) => set("bootstrapServers", event.target.value)} placeholder="broker:9092" required/></label><label className="dynamic-field"><span>Kafka Connect version</span><input className="input" value={form.kafkaConnectVersion} onChange={(event) => set("kafkaConnectVersion", event.target.value)} required/></label><label className="dynamic-field"><span>Service execution role ARN</span><input className="input" value={form.serviceExecutionRoleArn} onChange={(event) => set("serviceExecutionRoleArn", event.target.value)} required/></label><label className="dynamic-field"><span>Custom plugin</span><select className="input" value={`${form.customPluginArn}:${form.customPluginRevision}`} onChange={(event) => { const item = pluginsQuery.data?.find((candidate) => `${candidate.customPluginArn}:${candidate.revision ?? 1}` === event.target.value); if (item?.customPluginArn) { set("customPluginArn", item.customPluginArn); set("customPluginRevision", item.revision ?? 1); } }} required><option value=":1">Select a plugin</option>{pluginsQuery.data?.map((item) => <option key={`${item.customPluginArn}:${item.revision}`} value={`${item.customPluginArn}:${item.revision ?? 1}`}>{item.name} (rev {item.revision ?? 1})</option>)}</select></label><label className="dynamic-field"><span>Workers</span><input className="input" type="number" min="1" value={form.workerCount} onChange={(event) => set("workerCount", Number(event.target.value))}/></label><label className="dynamic-field"><span>MCUs per worker</span><select className="input" value={form.mcuCount} onChange={(event) => set("mcuCount", Number(event.target.value))}>{[1, 2, 4, 8].map((value) => <option key={value} value={value}>{value}</option>)}</select></label><label className="dynamic-field"><span>Topics</span><input className="input" value={form.topics} onChange={(event) => set("topics", event.target.value)} placeholder="orders,events" required/></label><ConverterField label="Key converter" value={form.keyConverter} onChange={(value) => set("keyConverter", value)}/><ConverterField label="Value converter" value={form.valueConverter} onChange={(value) => set("valueConverter", value)}/><BucketField label="S3 destination bucket" buckets={bucketsQuery.data?.map((bucket) => bucket.name) ?? []} value={form.s3Bucket} onChange={(value) => set("s3Bucket", value)}/><label className="dynamic-field"><span>S3 region</span><input className="input" value={form.s3Region} onChange={(event) => set("s3Region", event.target.value)} required/></label><label className="dynamic-field"><span>Flush size</span><input className="input" type="number" min="1" value={form.flushSize} onChange={(event) => set("flushSize", event.target.value)} required/></label><label className="dynamic-field"><span>Rotate interval (ms)</span><input className="input" type="number" min="0" value={form.rotateIntervalMs} onChange={(event) => set("rotateIntervalMs", event.target.value)} required/></label><label className="dynamic-field"><span>CloudWatch log group (optional)</span><input className="input" value={form.logGroup} onChange={(event) => set("logGroup", event.target.value)} placeholder="/aws/msk-connect/s3-sink"/></label><button className="button primary" type="submit" disabled={!runtimeReachable || !canCreate || createConnector.isPending}>{createConnector.isPending ? "Creating" : "Create S3 Sink"}</button></form>
</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Connectors</p><h3>State and configuration</h3><p className="muted compact-text">MSK Connect exposes connector state through DescribeConnector. It does not expose per-task status in its control-plane API.</p></div></div><label className="dynamic-field"><span>Connector</span><select className="input" value={selectedArn} onChange={(event) => setSelectedArn(event.target.value)}><option value="">Select a connector</option>{connectorsQuery.data?.map((item) => <option key={item.connectorArn} value={item.connectorArn}>{item.connectorName} {item.connectorState ? `(${item.connectorState})` : ""}</option>)}</select></label>{connectorQuery.data && <ConnectorStatus connector={connectorQuery.data}/>} {selectedArn && connectorQuery.data && <div className="button-row"><button className="button" type="button" disabled={updateConnector.isPending || !connectorQuery.data.currentVersion} onClick={() => updateConnector.mutate(connectorQuery.data)}>{updateConnector.isPending ? "Updating" : "Update S3 Sink settings"}</button><button className="button danger" type="button" disabled={removeConnector.isPending} onClick={() => removeConnector.mutate()}><Trash2 size={13}/>Delete connector</button></div>}{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}</section>
</div>;
}
function BucketField({label, buckets, value, onChange}: {label: string; buckets: string[]; value: string; onChange: (value: string) => void}) { return <label className="dynamic-field"><span>{label}</span><select className="input" value={value} onChange={(event) => onChange(event.target.value)} required><option value="">Select a bucket</option>{buckets.map((bucket) => <option key={bucket} value={bucket}>{bucket}</option>)}</select>{value && <Link to={`/cloud-explorer/aws/storage?resource=${encodeURIComponent(value)}`}>Open in S3 browser</Link>}</label>; }
function ConverterField({label, value, onChange}: {label: string; value: "string" | "json"; onChange: (value: "string" | "json") => void}) { return <label className="dynamic-field"><span>{label}</span><select className="input" value={value} onChange={(event) => onChange(event.target.value as "string" | "json")}><option value="string">String</option><option value="json">JSON</option></select></label>; }
function ConnectorStatus({connector}: {connector: ConnectorDescription}) { const logGroup = connector.logDelivery?.workerLogDelivery?.cloudWatchLogs?.logGroup; return <div className="apigateway-route-item"><div className="apigateway-route-select"><strong>{connector.connectorState ?? "Unknown"}</strong>{connector.stateDescription && <small>{connector.stateDescription}</small>}<small>Task status: not available from the MSK Connect API</small>{logGroup && <Link to={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(logGroup)}`}>Open {logGroup} in CloudWatch Logs</Link>}</div></div>; }