feat: add SQS + EventBridge

This commit is contained in:
Zakaria
2026-09-24 22:34:04 -04:00
parent 9bbe80b5f2
commit 1b129e9dcf
37 changed files with 2902 additions and 66 deletions
@@ -4,10 +4,16 @@ import { ChevronDown, ChevronUp, Info, Loader2, Plus, RefreshCw, Trash2, X } fro
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCloudResource,
createApiGatewayLambdaRoute,
createEventBridgeLambdaTrigger,
createS3LambdaTrigger,
createSqsLambdaEventSourceMapping,
clearEmailInbox,
deleteCloudResource,
getCloudResource,
getServiceSchema,
listCloudResources,
listStorageObjects,
updateCloudResource,
} from "@/api/cloudProxyClient";
import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
@@ -39,6 +45,12 @@ import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -73,6 +85,7 @@ export function DynamicResourceView({
const [selectedItems, setSelectedItems] = useState<CloudResource[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
@@ -138,8 +151,94 @@ export function DynamicResourceView({
};
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
createCloudResource(cloud, service, values),
mutationFn: async (values: Record<string, unknown>) => {
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
const blueprintScheduleExpression = typeof values.blueprintScheduleExpression === "string" ? values.blueprintScheduleExpression.trim() : "";
const blueprintQueueName = typeof values.blueprintQueueName === "string" ? values.blueprintQueueName.trim() : "";
const blueprintSqsBatchSize = typeof values.blueprintSqsBatchSize === "number" ? values.blueprintSqsBatchSize : undefined;
const lambdaValues = {...values};
delete lambdaValues.blueprintBucketName;
delete lambdaValues.blueprintApiName;
delete lambdaValues.blueprintApiPath;
delete lambdaValues.blueprintApiMethod;
delete lambdaValues.blueprintApiStage;
delete lambdaValues.blueprintRuleName;
delete lambdaValues.blueprintScheduleExpression;
delete lambdaValues.blueprintQueueName;
delete lambdaValues.blueprintSqsBatchSize;
const resource = await createCloudResource(cloud, service, lambdaValues);
if (blueprintBucketName) {
let bucketCreated = false;
try {
await createCloudResource(cloud, "storage", {bucketName: blueprintBucketName});
bucketCreated = true;
await createS3LambdaTrigger(cloud, blueprintBucketName, {
functionName: resource.name,
events: ["s3:ObjectCreated:*"],
});
return resource;
} catch (error) {
if (bucketCreated) await deleteCloudResource(cloud, "storage", blueprintBucketName);
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
}
if (blueprintRuleName) {
try {
await createEventBridgeLambdaTrigger(cloud, resource.name, {
ruleName: blueprintRuleName,
scheduleExpression: blueprintScheduleExpression,
});
return resource;
} catch (error) {
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
}
if (blueprintQueueName) {
let queueId: string | undefined;
try {
const queue = await createCloudResource(cloud, "messaging", {queueName: blueprintQueueName});
queueId = queue.id;
const queueArn = queue.metadata.arn;
if (typeof queueArn !== "string" || !queueArn) {
throw new Error("Created SQS queue did not return an ARN.");
}
await createSqsLambdaEventSourceMapping(cloud, resource.name, {
queueArn,
batchSize: blueprintSqsBatchSize,
});
return resource;
} catch (error) {
const rollbacks = [deleteCloudResource(cloud, service, resource.id)];
if (queueId) rollbacks.push(deleteCloudResource(cloud, "messaging", queueId));
await Promise.allSettled(rollbacks);
throw error;
}
}
if (!blueprintApiName) return resource;
let apiId: string | undefined;
try {
const api = await createCloudResource(cloud, "apigateway", {name: blueprintApiName});
apiId = api.id;
const route = await createApiGatewayLambdaRoute(cloud, api.id, {
functionName: resource.name,
path: String(values.blueprintApiPath ?? ""),
method: String(values.blueprintApiMethod ?? "GET"),
stage: String(values.blueprintApiStage ?? "dev"),
});
return {...resource, metadata: {...resource.metadata, apiGatewayId: api.id, apiGatewayInvokeUrl: route.invokeUrl}};
} catch (error) {
if (apiId) await deleteCloudResource(cloud, "apigateway", apiId);
await deleteCloudResource(cloud, service, resource.id);
throw error;
}
},
onSuccess: (resource) => {
setSelected(resource);
setCreateOpen(false);
@@ -150,15 +249,25 @@ export function DynamicResourceView({
});
const deleteMut = useMutation({
mutationFn: (resource: CloudResource) =>
deleteCloudResource(cloud, service, resource.id),
mutationFn: async (resource: CloudResource) => {
if (cloud === "aws" && service === "storage") {
const contents = await listStorageObjects(cloud, resource.id);
const warning = bucketDeleteWarning(contents);
if (warning) throw new Error(warning);
}
await deleteCloudResource(cloud, service, resource.id);
},
onSuccess: (_, resource) => {
setDeleteWarning(null);
if (selected?.id === resource.id) setSelected(undefined);
setSelectedItems((prev) => prev.filter((item) => item.id !== resource.id));
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
onError: (error) => {
setDeleteWarning(error instanceof Error ? error.message : "Unable to delete resource.");
},
});
const deleteMultipleMut = useMutation({
@@ -234,6 +343,13 @@ export function DynamicResourceView({
}
}, [successToast]);
useEffect(() => {
if (deleteWarning) {
const timer = setTimeout(() => setDeleteWarning(null), 5000);
return () => clearTimeout(timer);
}
}, [deleteWarning]);
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) =>
updateCloudResource(cloud, service, id, values),
@@ -280,8 +396,9 @@ export function DynamicResourceView({
if (!trimmed && !field.required) {
continue;
}
const initialStr =
initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
const initialStr = field.type === 'key-value' && initialVal && typeof initialVal === 'object'
? JSON.stringify(Object.entries(initialVal).map(([key, value]) => ({key, value: String(value)})))
: initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
if (trimmed !== initialStr) {
diff[field.name] = trimmed;
}
@@ -623,6 +740,14 @@ export function DynamicResourceView({
}}
onCancel={() => setCreateOpen(false)}
/>
) : service === "serverless" && cloud === "aws" ? (
<LambdaCreatePanel
schema={schema}
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : (
<DynamicFormRenderer
key={`${cloud}:${service}:create`}
@@ -658,9 +783,14 @@ export function DynamicResourceView({
schema.updateFields &&
schema.updateFields.length > 0 &&
schema.actions.includes("update")
? (resource) => {
setEditingResource(resource);
setUpdateError(null);
? async (resource) => {
try {
const detailedResource = await getCloudResource(cloud, service, resource.id);
setEditingResource(detailedResource);
setUpdateError(null);
} catch (error) {
setUpdateError(error instanceof Error ? error.message : "Unable to load resource configuration");
}
}
: undefined,
onDelete: (resource) => deleteMut.mutate(resource),
@@ -709,11 +839,31 @@ export function DynamicResourceView({
/>
)}
{service === "serverless" && (
<ServerlessInvokePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<>
<ServerlessInvokePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<S3LambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<ScheduledLambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<SqsLambdaTriggerPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
{cloud === "aws" && activeSelected?.type === "lambda" && (
<LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/>
)}
</>
)}
{service === "apigateway" && (
<ApiGatewayRoutePanel
@@ -803,6 +953,35 @@ export function DynamicResourceView({
{successToast}
</div>
)}
{deleteWarning && (
<div
role="alert"
style={{
position: "fixed",
bottom: 24,
right: 24,
background: "var(--status-error, #dc2626)",
color: "#fff",
padding: "10px 18px",
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
zIndex: 1000,
fontSize: 13,
fontWeight: 500,
}}
>
<span>{deleteWarning}</span>
<button
className="icon-btn"
type="button"
aria-label="Dismiss warning"
onClick={() => setDeleteWarning(null)}
style={{ marginLeft: 10, color: "inherit" }}
>
<X size={14} />
</button>
</div>
)}
</div>
);
}