feat: EC2 UI polished + storage persisting
This commit is contained in:
@@ -22,6 +22,10 @@ export const apiEndpointKeys = {
|
||||
update: "clouds.services.resources.update",
|
||||
delete: "clouds.services.resources.delete",
|
||||
invoke: "clouds.services.resources.invoke",
|
||||
publish: "clouds.services.resources.notifications.publish",
|
||||
createSnsSubscription: "clouds.services.resources.notifications.subscription.create",
|
||||
listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list",
|
||||
deleteSnsSubscription: "clouds.services.resources.notifications.subscription.delete",
|
||||
encrypt: "clouds.services.resources.encrypt",
|
||||
decrypt: "clouds.services.resources.decrypt",
|
||||
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
|
||||
@@ -33,7 +37,10 @@ export const apiEndpointKeys = {
|
||||
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
|
||||
createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create",
|
||||
listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list",
|
||||
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
|
||||
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
|
||||
createEventBridgeRule: "clouds.services.resources.events.rule.create",
|
||||
listEventBridgeRules: "clouds.services.resources.events.rules.list",
|
||||
deleteEventBridgeRule: "clouds.services.resources.events.rule.delete",
|
||||
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
|
||||
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
|
||||
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
|
||||
@@ -297,7 +304,7 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.invoke,
|
||||
apiEndpointKeys.clouds.resources.invoke,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/invoke",
|
||||
method: "POST",
|
||||
@@ -312,6 +319,38 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.publish,
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/publish",
|
||||
method: "POST",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.createSnsSubscription,
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions",
|
||||
method: "POST",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.listSnsSubscriptions,
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions",
|
||||
method: "GET",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.deleteSnsSubscription,
|
||||
{
|
||||
path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions/:subscriptionArn",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "notifications" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.listApiGatewayRoutes,
|
||||
{
|
||||
@@ -384,6 +423,30 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
telemetry: { service: "serverless" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.createEventBridgeRule,
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/rules",
|
||||
method: "POST",
|
||||
telemetry: { service: "events" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.listEventBridgeRules,
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/rules",
|
||||
method: "GET",
|
||||
telemetry: { service: "events" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.deleteEventBridgeRule,
|
||||
{
|
||||
path: "/clouds/:cloud/services/events/resources/:id/rules/:ruleName",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "events" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
|
||||
{
|
||||
|
||||
@@ -77,6 +77,8 @@ export type Ec2KeyPairMaterial = {
|
||||
keyMaterial: string;
|
||||
};
|
||||
|
||||
export type Ec2KeyPairType = "rsa" | "ed25519";
|
||||
|
||||
export type Ec2Vpc = {
|
||||
vpcId: string;
|
||||
cidrBlock: string;
|
||||
@@ -344,10 +346,10 @@ export async function listEc2KeyPairs(signal?: AbortSignal): Promise<Ec2KeyPair[
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2KeyPair(name: string): Promise<Ec2KeyPairMaterial> {
|
||||
const res = await apiClient.call<Ec2KeyPairMaterial>(
|
||||
export async function createEc2KeyPair(name: string, keyType: Ec2KeyPairType = "rsa"): Promise<Ec2KeyPairMaterial> {
|
||||
const res = await apiClient.call<Ec2KeyPairMaterial, {name: string; keyType: Ec2KeyPairType}>(
|
||||
apiEndpointKeys.aws.ec2.keyPairs.create,
|
||||
{ body: JSON.stringify({ name }), headers: { "content-type": "application/json" } },
|
||||
{ body: { name, keyType } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,16 @@ export interface ServerlessInvokeResult {
|
||||
executionDuration?: number;
|
||||
}
|
||||
|
||||
export interface PublishNotificationInput {
|
||||
message: string;
|
||||
subject?: string;
|
||||
messageAttributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PublishNotificationResult {
|
||||
messageId: string | null;
|
||||
}
|
||||
|
||||
export interface CreateApiGatewayLambdaRouteInput {
|
||||
path: string;
|
||||
method: string;
|
||||
@@ -268,6 +278,19 @@ export interface S3LambdaTriggerOverview {
|
||||
suffix: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSnsSubscriptionInput {
|
||||
protocol: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export interface SnsSubscriptionOverview {
|
||||
arn: string | null;
|
||||
protocol: string;
|
||||
endpoint: string;
|
||||
status: "Confirmed" | "PendingConfirmation";
|
||||
filterPolicy: string | null;
|
||||
}
|
||||
|
||||
export interface CreateEventBridgeLambdaTriggerInput {
|
||||
ruleName: string;
|
||||
scheduleExpression: string;
|
||||
@@ -283,6 +306,27 @@ export interface EventBridgeLambdaTriggerOverview {
|
||||
state: string | null;
|
||||
}
|
||||
|
||||
export interface EventBridgeRuleTarget {
|
||||
id: string;
|
||||
arn: string;
|
||||
}
|
||||
|
||||
export interface CreateEventBridgeRuleInput {
|
||||
name: string;
|
||||
scheduleExpression?: string;
|
||||
eventPattern?: string;
|
||||
state?: "ENABLED" | "DISABLED";
|
||||
description?: string;
|
||||
targets: EventBridgeRuleTarget[];
|
||||
}
|
||||
|
||||
export interface EventBridgeRuleOverview extends CreateEventBridgeRuleInput {
|
||||
busName: string;
|
||||
busId: string;
|
||||
arn: string | null;
|
||||
state: "ENABLED" | "DISABLED";
|
||||
}
|
||||
|
||||
export interface CreateSqsLambdaEventSourceMappingInput {
|
||||
queueArn: string;
|
||||
batchSize?: number;
|
||||
@@ -393,6 +437,46 @@ export async function deleteS3LambdaTrigger(
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSnsSubscription(
|
||||
cloud: CloudProvider,
|
||||
topicArn: string,
|
||||
input: CreateSnsSubscriptionInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SnsSubscriptionOverview> {
|
||||
const res = await apiClient.call<SnsSubscriptionOverview, CreateSnsSubscriptionInput>(
|
||||
apiEndpointKeys.clouds.resources.createSnsSubscription,
|
||||
requestOptions(cloud, "notifications", {signal, body: input}),
|
||||
{cloud, id: topicArn},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listSnsSubscriptions(
|
||||
cloud: CloudProvider,
|
||||
topicArn: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SnsSubscriptionOverview[]> {
|
||||
const res = await apiClient.call<SnsSubscriptionOverview[]>(
|
||||
apiEndpointKeys.clouds.resources.listSnsSubscriptions,
|
||||
requestOptions(cloud, "notifications", {signal}),
|
||||
{cloud, id: topicArn},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteSnsSubscription(
|
||||
cloud: CloudProvider,
|
||||
topicArn: string,
|
||||
subscriptionArn: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.resources.deleteSnsSubscription,
|
||||
requestOptions(cloud, "notifications", {signal}),
|
||||
{cloud, id: topicArn, subscriptionArn},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEventBridgeLambdaTrigger(
|
||||
cloud: CloudProvider,
|
||||
functionName: string,
|
||||
@@ -433,6 +517,46 @@ export async function deleteEventBridgeLambdaTrigger(
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEventBridgeRule(
|
||||
cloud: CloudProvider,
|
||||
eventBusId: string,
|
||||
input: CreateEventBridgeRuleInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EventBridgeRuleOverview> {
|
||||
const res = await apiClient.call<EventBridgeRuleOverview, CreateEventBridgeRuleInput>(
|
||||
apiEndpointKeys.clouds.resources.createEventBridgeRule,
|
||||
requestOptions(cloud, "events", {signal, body: input}),
|
||||
{cloud, id: eventBusId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEventBridgeRules(
|
||||
cloud: CloudProvider,
|
||||
eventBusId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EventBridgeRuleOverview[]> {
|
||||
const res = await apiClient.call<EventBridgeRuleOverview[]>(
|
||||
apiEndpointKeys.clouds.resources.listEventBridgeRules,
|
||||
requestOptions(cloud, "events", {signal}),
|
||||
{cloud, id: eventBusId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEventBridgeRule(
|
||||
cloud: CloudProvider,
|
||||
eventBusId: string,
|
||||
ruleName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.resources.deleteEventBridgeRule,
|
||||
requestOptions(cloud, "events", {signal}),
|
||||
{cloud, id: eventBusId, ruleName},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSqsLambdaEventSourceMapping(
|
||||
cloud: CloudProvider,
|
||||
functionName: string,
|
||||
@@ -525,6 +649,20 @@ export async function invokeCloudResource(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function publishNotification(
|
||||
cloud: CloudProvider,
|
||||
topicArn: string,
|
||||
input: PublishNotificationInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PublishNotificationResult> {
|
||||
const res = await apiClient.call<PublishNotificationResult, PublishNotificationInput>(
|
||||
apiEndpointKeys.clouds.resources.publish,
|
||||
requestOptions(cloud, "notifications", { signal, body: input }),
|
||||
{ cloud, id: topicArn },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function encryptKmsResource(
|
||||
cloud: CloudProvider,
|
||||
id: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {
|
||||
authorizeEc2SecurityGroupIngress,
|
||||
createEc2Ami,
|
||||
createEc2KeyPair,
|
||||
createEc2SecurityGroup,
|
||||
deregisterEc2Ami,
|
||||
describeEc2Instance,
|
||||
@@ -42,6 +44,10 @@ import {
|
||||
import { createCloudResource } from '@/api/cloudProxyClient'
|
||||
import type { CloudProvider } from '@/types/cloud'
|
||||
import type { CloudResource } from '@/types/resource'
|
||||
import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png'
|
||||
import debianImage from '../../../../OS_img/Debian_linux.png'
|
||||
import redHatImage from '../../../../OS_img/red-hat-logo.png'
|
||||
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -343,11 +349,16 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['ec2', 'instance', instanceId],
|
||||
queryFn: ({ signal }) => describeEc2Instance(instanceId, signal),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.state
|
||||
return state === 'pending' || state === 'stopping' || state === 'shutting-down' ? 1_500 : false
|
||||
},
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['ec2', 'instance', instanceId] })
|
||||
void qc.invalidateQueries({ queryKey: ['ec2', 'instances'] })
|
||||
void qc.invalidateQueries({ queryKey: ['cloud-resources', 'aws', 'compute'] })
|
||||
}
|
||||
|
||||
const startMut = useMutation({
|
||||
@@ -678,6 +689,32 @@ export function ComputePanel({ cloud, resource, runtimeReachable }: ComputePanel
|
||||
|
||||
type InboundRule = { id: string; protocol: string; portInput: string; cidr: string }
|
||||
|
||||
type LaunchStep = 1 | 2 | 3 | 4
|
||||
|
||||
type AmiFamily = 'amazon-linux' | 'fedora' | 'ubuntu' | 'red-hat' | 'debian' | 'suse' | 'other'
|
||||
|
||||
type AmiClassification = { family: AmiFamily; label: string; mark: string; color: string }
|
||||
|
||||
function classifyAmi(ami: Ec2Image): AmiClassification {
|
||||
const metadata = `${ami.name} ${ami.description ?? ''} ${ami.platform ?? ''}`.toLowerCase()
|
||||
if (metadata.includes('ubuntu')) return { family: 'ubuntu', label: 'Ubuntu', mark: 'U', color: '#e95420' }
|
||||
if (metadata.includes('amazon linux') || metadata.includes('amzn')) return { family: 'amazon-linux', label: 'Amazon Linux', mark: 'A', color: '#ff9900' }
|
||||
if (metadata.includes('fedora')) return { family: 'fedora', label: 'Fedora', mark: 'F', color: '#51a2da' }
|
||||
if (metadata.includes('red hat') || metadata.includes('rhel')) return { family: 'red-hat', label: 'Red Hat', mark: 'R', color: '#ee0000' }
|
||||
if (metadata.includes('debian')) return { family: 'debian', label: 'Debian', mark: 'D', color: '#a80030' }
|
||||
if (metadata.includes('suse')) return { family: 'suse', label: 'SUSE', mark: 'S', color: '#30ba78' }
|
||||
return { family: 'other', label: ami.platform || 'Other', mark: '?', color: 'var(--text-2)' }
|
||||
}
|
||||
|
||||
const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: string; image: string; preferredImageId?: string }> = [
|
||||
{ family: 'amazon-linux', label: 'Amazon Linux 2023', image: amazonLinuxImage, preferredImageId: 'ami-0abcdef1234567891' },
|
||||
{ family: 'fedora', label: 'Fedora', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2351a2da"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="34" font-weight="bold" fill="white"%3EF%3C/text%3E%3C/svg%3E' },
|
||||
{ family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage },
|
||||
{ family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' },
|
||||
{ family: 'red-hat', label: 'Red Hat', image: redHatImage },
|
||||
{ family: 'debian', label: 'Debian', image: debianImage },
|
||||
]
|
||||
|
||||
const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [
|
||||
{ label: 'SSH', protocol: 'tcp', portInput: '22', cidr: '0.0.0.0/0' },
|
||||
{ label: 'HTTP', protocol: 'tcp', portInput: '80', cidr: '0.0.0.0/0' },
|
||||
@@ -714,18 +751,23 @@ export function LaunchInstanceForm({
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Instance fields
|
||||
const [step, setStep] = useState<LaunchStep>(1)
|
||||
const [stepError, setStepError] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [imageId, setImageId] = useState(selectedResource?.type === 'image' ? selectedResource.id : '')
|
||||
const [architectureFilter, setArchitectureFilter] = useState('')
|
||||
const [amiSearch, setAmiSearch] = useState('')
|
||||
const [showAmiBrowser, setShowAmiBrowser] = useState(false)
|
||||
const [instanceType, setInstanceType] = useState('t3.micro')
|
||||
|
||||
// Networking fields
|
||||
const [keyName, setKeyName] = useState('')
|
||||
const [keyPairMode, setKeyPairMode] = useState<'existing' | 'new'>('existing')
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [newKeyType, setNewKeyType] = useState<'rsa' | 'ed25519'>('rsa')
|
||||
const [createdKeyMaterial, setCreatedKeyMaterial] = useState<{ name: string; material: string }>()
|
||||
const [rootVolumeSize, setRootVolumeSize] = useState('20')
|
||||
const [rootVolumeType, setRootVolumeType] = useState('gp3')
|
||||
const [subnetId, setSubnetId] = useState('')
|
||||
const [selectedSgs, setSelectedSgs] = useState<string[]>([])
|
||||
|
||||
// Inline SG creation
|
||||
const [showCreateSg, setShowCreateSg] = useState(false)
|
||||
const [newSgName, setNewSgName] = useState('')
|
||||
const [newSgDesc, setNewSgDesc] = useState('')
|
||||
@@ -756,6 +798,15 @@ export function LaunchInstanceForm({
|
||||
},
|
||||
})
|
||||
|
||||
const createKeyMut = useMutation({
|
||||
mutationFn: () => createEc2KeyPair(newKeyName.trim(), newKeyType),
|
||||
onSuccess: (keyPair) => {
|
||||
setKeyName(keyPair.keyName)
|
||||
setCreatedKeyMaterial({ name: keyPair.keyName, material: keyPair.keyMaterial })
|
||||
void qc.invalidateQueries({ queryKey: ['ec2', 'key-pairs'] })
|
||||
},
|
||||
})
|
||||
|
||||
const createSgMut = useMutation({
|
||||
mutationFn: async (vars: {name: string; desc: string; vpc: string; rules: InboundRule[]}) => {
|
||||
const { groupId } = await createEc2SecurityGroup(vars.name, vars.desc || vars.name, vars.vpc || undefined)
|
||||
@@ -793,8 +844,7 @@ export function LaunchInstanceForm({
|
||||
setSelectedSgs((prev) => checked ? [...prev, groupId] : prev.filter((id) => id !== groupId))
|
||||
}
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
function launch() {
|
||||
createMut.mutate({
|
||||
name,
|
||||
imageId,
|
||||
@@ -802,9 +852,34 @@ export function LaunchInstanceForm({
|
||||
keyName: keyName || undefined,
|
||||
subnetId: subnetId || undefined,
|
||||
securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined,
|
||||
rootVolumeSize,
|
||||
rootVolumeType,
|
||||
})
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step === 1 && (!name.trim() || !imageId)) {
|
||||
setStepError('Enter an instance name and select an AMI to continue.')
|
||||
return
|
||||
}
|
||||
if (step === 2 && keyPairMode === 'new' && !keyName) {
|
||||
setStepError('Create the new key pair before continuing, or choose an existing key pair.')
|
||||
return
|
||||
}
|
||||
setStepError('')
|
||||
setStep((current) => Math.min(4, current + 1) as LaunchStep)
|
||||
}
|
||||
|
||||
function downloadPrivateKey() {
|
||||
if (!createdKeyMaterial) return
|
||||
const href = URL.createObjectURL(new Blob([createdKeyMaterial.material], { type: 'application/x-pem-file' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = href
|
||||
link.download = `${createdKeyMaterial.name}.pem`
|
||||
link.click()
|
||||
URL.revokeObjectURL(href)
|
||||
}
|
||||
|
||||
const vpcs = vpcsQ.data ?? []
|
||||
const subnets = subsQ.data ?? []
|
||||
const groupByVpc = vpcs.length > 2
|
||||
@@ -814,75 +889,148 @@ export function LaunchInstanceForm({
|
||||
return tag ? `${tag} (${vpc.vpcId})` : vpc.vpcId
|
||||
}
|
||||
|
||||
const amis = amisQ.data ?? []
|
||||
const architectures = [...new Set(amis.map((ami) => ami.architecture).filter((architecture): architecture is string => Boolean(architecture)))].sort()
|
||||
const filteredAmis = amis.filter((ami) => !architectureFilter || ami.architecture === architectureFilter)
|
||||
const quickStartAmis = QUICK_START_AMIS.map((catalogAmi) => ({
|
||||
...catalogAmi,
|
||||
ami: filteredAmis.find((candidate) => candidate.imageId === catalogAmi.preferredImageId)
|
||||
?? filteredAmis.find((candidate) => classifyAmi(candidate).family === catalogAmi.family),
|
||||
}))
|
||||
const quickStartImageIds = new Set(quickStartAmis.flatMap((catalogAmi) => catalogAmi.ami ? [catalogAmi.ami.imageId] : []))
|
||||
const normalizedAmiSearch = amiSearch.trim().toLowerCase()
|
||||
const visibleQuickStartAmis = quickStartAmis.filter((catalogAmi) => {
|
||||
const searchable = `${catalogAmi.label} ${catalogAmi.ami?.imageId ?? ''} ${catalogAmi.ami?.name ?? ''} ${catalogAmi.ami?.description ?? ''}`.toLowerCase()
|
||||
return !normalizedAmiSearch || searchable.includes(normalizedAmiSearch)
|
||||
})
|
||||
const browsableAmis = filteredAmis.filter((ami) => {
|
||||
const searchable = `${ami.imageId} ${ami.name} ${ami.description ?? ''} ${classifyAmi(ami).label}`.toLowerCase()
|
||||
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
|
||||
})
|
||||
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
|
||||
const selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId)
|
||||
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
|
||||
const steps: Array<{ number: LaunchStep; label: string }> = [
|
||||
{ number: 1, label: 'Name and AMI' },
|
||||
{ number: 2, label: 'Instance and key pair' },
|
||||
{ number: 3, label: 'Network and security' },
|
||||
{ number: 4, label: 'Storage and review' },
|
||||
]
|
||||
|
||||
return (
|
||||
<form className="launch-form" onSubmit={submit}>
|
||||
{/* ── Required ──────────────────────────────────────────── */}
|
||||
<div className="launch-form-group">Required</div>
|
||||
<form className="launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
|
||||
<div className="sns-tabs" aria-label="Launch steps" style={{ gridColumn: '1 / -1', overflowX: 'auto' }}>
|
||||
{steps.map((item) => (
|
||||
<button key={item.number} type="button" className={`sns-tab${step === item.number ? ' active' : ''}`} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
|
||||
{item.number}. {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Instance Name <em className="field-required">*</em></span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g. my-web-server"/>
|
||||
</label>
|
||||
{step === 1 && <>
|
||||
<div className="launch-form-group">Name and AMI</div>
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Instance name <em className="field-required">*</em></span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. my-web-server" />
|
||||
</label>
|
||||
<label className="launch-field">
|
||||
<span>Architecture</span>
|
||||
<select className="input" value={architectureFilter} onChange={(e) => { setArchitectureFilter(e.target.value); setImageId('') }}>
|
||||
<option value="">All architectures</option>
|
||||
{architectures.map((architecture) => <option key={architecture} value={architecture}>{architecture}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Amazon Machine Image <em className="field-required">*</em></span>
|
||||
<div className="ami-catalog">
|
||||
<div className="ami-search">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input className="input" value={amiSearch} onChange={(e) => { setAmiSearch(e.target.value); setShowAmiBrowser(Boolean(e.target.value)) }} placeholder="Search AMIs" aria-label="Search available AMIs" />
|
||||
</div>
|
||||
{amisQ.isLoading && <span className="muted">Loading AMIs…</span>}
|
||||
{!amisQ.isLoading && <>
|
||||
<div className="ami-catalog-heading">Quick Start</div>
|
||||
<div className="ami-quick-start-grid">
|
||||
{visibleQuickStartAmis.map((catalogAmi) => (
|
||||
<button
|
||||
key={catalogAmi.family}
|
||||
type="button"
|
||||
className={`ami-quick-start-tile${imageId === catalogAmi.ami?.imageId ? ' selected' : ''}${catalogAmi.ami ? '' : ' unavailable'}`}
|
||||
disabled={!catalogAmi.ami}
|
||||
onClick={() => { if (catalogAmi.ami) { setImageId(catalogAmi.ami.imageId); setArchitectureFilter(catalogAmi.ami.architecture ?? '') } }}
|
||||
title={catalogAmi.ami ? catalogAmi.ami.name || catalogAmi.ami.imageId : `No local ${catalogAmi.label} AMI is available`}
|
||||
>
|
||||
<img src={catalogAmi.image} alt="" />
|
||||
<span>{catalogAmi.label}</span>
|
||||
<small>{catalogAmi.ami?.architecture || 'No local AMI'}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
{!amisQ.isLoading && amis.length === 0 && <span className="muted">No AMIs are available.</span>}
|
||||
{!amisQ.isLoading && amis.length > 0 && <>
|
||||
<button type="button" className="ami-browser-toggle" onClick={() => setShowAmiBrowser((visible) => !visible)}>
|
||||
{showAmiBrowser ? 'Hide available AMIs' : 'Browse available AMIs'}
|
||||
</button>
|
||||
{showAmiBrowser && <div className="ami-browser-list">
|
||||
{browsableAmis.map((ami) => {
|
||||
const distro = classifyAmi(ami)
|
||||
return <label key={ami.imageId} className="ami-browser-item">
|
||||
<input type="radio" name="ami" checked={imageId === ami.imageId} onChange={() => { setImageId(ami.imageId); setArchitectureFilter(ami.architecture ?? '') }} />
|
||||
<span><b style={{ background: distro.color }}>{distro.mark}</b>{ami.name || ami.imageId}</span>
|
||||
<code>{ami.imageId} · {distro.label}</code>
|
||||
</label>
|
||||
})}
|
||||
{browsableAmis.length === 0 && <span className="muted">No additional AMIs match this search and architecture.</span>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<label className="launch-field">
|
||||
<span>AMI <em className="field-required">*</em></span>
|
||||
<select className="input" value={imageId} onChange={(e) => setImageId(e.target.value)} required>
|
||||
<option value="">Select an AMI…</option>
|
||||
{(amisQ.data ?? []).map((ami: Ec2Image) => (
|
||||
<option key={ami.imageId} value={ami.imageId}>{ami.name || ami.imageId}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{step === 2 && <>
|
||||
<div className="launch-form-group">Instance type and key pair</div>
|
||||
<label className="launch-field">
|
||||
<span>Instance type <em className="field-required">*</em></span>
|
||||
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
|
||||
{INSTANCE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Key pair</span>
|
||||
<div className="field-row" style={{ marginTop: 6 }}>
|
||||
<label><input type="radio" checked={keyPairMode === 'existing'} onChange={() => setKeyPairMode('existing')} /> Choose existing</label>
|
||||
<label><input type="radio" checked={keyPairMode === 'new'} onChange={() => { setKeyPairMode('new'); setKeyName('') }} /> Create new</label>
|
||||
</div>
|
||||
{keyPairMode === 'existing' ? (
|
||||
<select className="input" style={{ marginTop: 8 }} value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<option value="">No key pair</option>
|
||||
{(keysQ.data ?? []).map((keyPair: Ec2KeyPair) => <option key={keyPair.keyPairId} value={keyPair.keyName}>{keyPair.keyName}</option>)}
|
||||
</select>
|
||||
) : <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<input className="input" value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value); setKeyName('') }} placeholder="Key pair name" />
|
||||
<select className="input" value={newKeyType} onChange={(e) => setNewKeyType(e.target.value as 'rsa' | 'ed25519')}><option value="rsa">RSA</option><option value="ed25519">ED25519</option></select>
|
||||
<button type="button" className="button" disabled={!newKeyName.trim() || createKeyMut.isPending} onClick={() => createKeyMut.mutate()}>{createKeyMut.isPending ? 'Creating…' : 'Create key pair'}</button>
|
||||
{createdKeyMaterial && <button type="button" className="button primary" onClick={downloadPrivateKey}>Download {createdKeyMaterial.name}.pem</button>}
|
||||
</div>}
|
||||
{createKeyMut.error instanceof Error && <p className="form-error" style={{ marginTop: 8 }}>{createKeyMut.error.message}</p>}
|
||||
{createdKeyMaterial && <p className="muted" style={{ margin: '8px 0 0' }}>The private key is available only from this creation response. Download it before leaving this wizard.</p>}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Instance Type <em className="field-required">*</em></span>
|
||||
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)} required>
|
||||
{INSTANCE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* ── Networking — optional ─────────────────────────────── */}
|
||||
<div className="launch-form-group">Networking — optional</div>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Key Pair</span>
|
||||
<select className="input" value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<option value="">None</option>
|
||||
{(keysQ.data ?? []).map((kp: Ec2KeyPair) => (
|
||||
<option key={kp.keyPairId} value={kp.keyName}>{kp.keyName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Subnet</span>
|
||||
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
|
||||
<option value="">Default</option>
|
||||
{groupByVpc
|
||||
? vpcs.map((vpc: Ec2Vpc) => {
|
||||
const vpcSubnets = subnets.filter((s: Ec2Subnet) => s.vpcId === vpc.vpcId)
|
||||
if (!vpcSubnets.length) return null
|
||||
return (
|
||||
<optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>
|
||||
{vpcSubnets.map((s: Ec2Subnet) => (
|
||||
<option key={s.subnetId} value={s.subnetId}>
|
||||
{s.availabilityZone} — {s.cidrBlock}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)
|
||||
})
|
||||
: subnets.map((s: Ec2Subnet) => (
|
||||
<option key={s.subnetId} value={s.subnetId}>
|
||||
{s.availabilityZone} — {s.cidrBlock}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* ── Security Groups ───────────────────────────────────── */}
|
||||
<div className="launch-field launch-field--span">
|
||||
{step === 3 && <>
|
||||
<div className="launch-form-group">Network and security</div>
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Subnet</span>
|
||||
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
|
||||
<option value="">Default subnet selection</option>
|
||||
{groupByVpc ? vpcs.map((vpc: Ec2Vpc) => {
|
||||
const vpcSubnets = subnets.filter((subnet: Ec2Subnet) => subnet.vpcId === vpc.vpcId)
|
||||
return vpcSubnets.length ? <optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>{vpcSubnets.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}</optgroup> : null
|
||||
}) : subnets.map((subnet: Ec2Subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<div className="sg-header">
|
||||
<span>Security Groups</span>
|
||||
<button
|
||||
@@ -934,7 +1082,6 @@ export function LaunchInstanceForm({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Inbound Rules */}
|
||||
<div className="sg-rules-section">
|
||||
<div className="sg-rules-header">
|
||||
<span className="sg-rules-label">Inbound Rules</span>
|
||||
@@ -1008,18 +1155,45 @@ export function LaunchInstanceForm({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{createMut.error instanceof Error && (
|
||||
<div className="form-error launch-field--span">{createMut.error.message}</div>
|
||||
)}
|
||||
{step === 4 && <>
|
||||
<div className="launch-form-group">Storage and review</div>
|
||||
<label className="launch-field">
|
||||
<span>Root volume size (GiB)</span>
|
||||
<input className="input" type="number" min="1" value={rootVolumeSize} onChange={(event) => setRootVolumeSize(event.target.value)} />
|
||||
<small>Creates the root EBS volume at this size.</small>
|
||||
</label>
|
||||
<label className="launch-field">
|
||||
<span>Root volume type</span>
|
||||
<select className="input" value={rootVolumeType} onChange={(event) => setRootVolumeType(event.target.value)}>
|
||||
<option value="gp3">General Purpose SSD (gp3)</option>
|
||||
<option value="gp2">General Purpose SSD (gp2)</option>
|
||||
<option value="io1">Provisioned IOPS SSD (io1)</option>
|
||||
<option value="st1">Throughput Optimized HDD (st1)</option>
|
||||
<option value="sc1">Cold HDD (sc1)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Review: fields sent to RunInstances</span>
|
||||
<div className="meta-grid" style={{ marginTop: 8 }}>
|
||||
<div className="meta-row"><span className="meta-label">Name</span><span className="meta-value">{name}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">AMI</span><span className="meta-value">{selectedAmi?.name || imageId} ({imageId})</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Instance type</span><span className="meta-value">{instanceType}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Key pair</span><span className="meta-value">{keyName || 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Subnet</span><span className="meta-value">{selectedSubnet ? `${selectedSubnet.availabilityZone} (${subnetId})` : 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Security groups</span><span className="meta-value">{selectedGroups.length ? selectedGroups.map((group) => group.groupName || group.groupId).join(', ') : 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Root volume</span><span className="meta-value">{rootVolumeSize} GiB {rootVolumeType}, delete on termination</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="launch-form-actions">
|
||||
<button type="button" className="button" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="button primary" disabled={createMut.isPending}>
|
||||
<Plus size={14}/>
|
||||
{createMut.isPending ? 'Launching…' : 'Launch instance'}
|
||||
</button>
|
||||
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
|
||||
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
|
||||
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
|
||||
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
|
||||
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -48,9 +48,11 @@ import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
||||
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
||||
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
||||
import { EventBridgeRulePanel } from "@/components/EventBridgeRulePanel";
|
||||
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
|
||||
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
|
||||
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
|
||||
import { SnsTopicPanel } from "@/components/SnsTopicPanel";
|
||||
|
||||
interface DynamicResourceViewProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -87,6 +89,12 @@ export function DynamicResourceView({
|
||||
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
|
||||
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cloud === "aws" && service === "compute") {
|
||||
setCreateOpen(true);
|
||||
}
|
||||
}, [cloud, service]);
|
||||
|
||||
|
||||
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
|
||||
setDatabaseTab(tab);
|
||||
@@ -766,7 +774,7 @@ export function DynamicResourceView({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderResourceSurface({
|
||||
{!(cloud === "aws" && service === "compute" && createOpen) && renderResourceSurface({
|
||||
schema,
|
||||
resources,
|
||||
selectedId: activeSelected?.id,
|
||||
@@ -872,6 +880,13 @@ export function DynamicResourceView({
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "events" && (
|
||||
<EventBridgeRulePanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "configuration" && cloud === "aws" && (
|
||||
<AppConfigPanel
|
||||
cloud={cloud}
|
||||
@@ -886,6 +901,13 @@ export function DynamicResourceView({
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "notifications" && (
|
||||
<SnsTopicPanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{editingResource && schema.updateFields && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {EventBridgeRulePanel} from './EventBridgeRulePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createEventBridgeRule: vi.fn(),
|
||||
deleteEventBridgeRule: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
listEventBridgeRules: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const eventBus: CloudResource = {id: 'event-bus:orders', name: 'orders', cloud: 'aws', service: 'events', type: 'event-bus', region: null, createdAt: null, metadata: {}}
|
||||
const lambda: CloudResource = {id: 'worker', name: 'worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}}
|
||||
const topic: CloudResource = {id: 'orders-topic', name: 'orders-topic', cloud: 'aws', service: 'notifications', type: 'topic', region: null, createdAt: null, metadata: {arn: 'arn:aws:sns:us-east-1:000000000000:orders-topic'}}
|
||||
|
||||
function renderPanel() {
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
render(<QueryClientProvider client={queryClient}><EventBridgeRulePanel cloud="aws" resource={eventBus} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
test('creates a scheduled EventBridge rule with the selected resource ARN', async () => {
|
||||
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'serverless' ? [lambda] : []))
|
||||
apiMocks.listEventBridgeRules.mockResolvedValue([])
|
||||
apiMocks.createEventBridgeRule.mockResolvedValue({name: 'hourly-report'})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await screen.findByRole('option', {name: 'worker'})
|
||||
await user.type(screen.getByLabelText(/rule name/i), 'hourly-report')
|
||||
await user.selectOptions(screen.getByLabelText(/target resource/i), String(lambda.metadata.arn))
|
||||
expect(screen.getByText(/No Lambda, SQS queue, SNS topic, or Step Functions resources will be created/i)).toBeVisible()
|
||||
await user.click(screen.getByRole('button', {name: 'Create rule'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', 'event-bus:orders', {
|
||||
name: 'hourly-report', scheduleExpression: 'rate(5 minutes)', state: 'ENABLED', targets: [{id: 'worker', arn: lambda.metadata.arn}],
|
||||
}))
|
||||
})
|
||||
|
||||
test('creates an EventBridge rule with a selected SNS topic target', async () => {
|
||||
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'notifications' ? [topic] : []))
|
||||
apiMocks.listEventBridgeRules.mockResolvedValue([])
|
||||
apiMocks.createEventBridgeRule.mockResolvedValue({name: 'topic-notifications'})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await screen.findByRole('option', {name: 'orders-topic'})
|
||||
await user.selectOptions(screen.getByLabelText(/target service/i), 'sns')
|
||||
await user.type(screen.getByLabelText(/rule name/i), 'topic-notifications')
|
||||
await user.selectOptions(screen.getByLabelText(/target resource/i), String(topic.metadata.arn))
|
||||
await user.click(screen.getByRole('button', {name: 'Create rule'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, {
|
||||
name: 'topic-notifications', scheduleExpression: 'rate(5 minutes)', state: 'ENABLED', targets: [{id: 'orders-topic', arn: topic.metadata.arn}],
|
||||
}))
|
||||
})
|
||||
|
||||
test('creates an event-pattern rule and confirms before deletion', async () => {
|
||||
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'serverless' ? [lambda] : []))
|
||||
apiMocks.listEventBridgeRules.mockResolvedValue([{name: 'billing-events', busName: 'orders', busId: eventBus.id, arn: null, eventPattern: '{"source":["billing"]}', state: 'DISABLED', targets: [{id: 'worker', arn: String(lambda.metadata.arn)}]}])
|
||||
apiMocks.createEventBridgeRule.mockResolvedValue({name: 'billing-events'})
|
||||
apiMocks.deleteEventBridgeRule.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByLabelText(/mode/i))
|
||||
await user.selectOptions(screen.getByLabelText(/mode/i), 'event-pattern')
|
||||
await user.type(screen.getByLabelText(/rule name/i), 'billing-events')
|
||||
await user.selectOptions(screen.getByLabelText(/target resource/i), String(lambda.metadata.arn))
|
||||
await user.click(screen.getByRole('button', {name: 'Create rule'}))
|
||||
await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, expect.objectContaining({eventPattern: '{\n "source": ["orders"]\n}'})))
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Delete rule billing-events'}))
|
||||
expect(screen.getByText('Delete rule?')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', {name: 'Delete'}))
|
||||
await waitFor(() => expect(apiMocks.deleteEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, 'billing-events'))
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {CalendarClock, Loader2, Plus, Radio, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, type EventBridgeRuleOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider, CloudServiceType} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface EventBridgeRulePanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type TargetService = 'lambda' | 'sqs' | 'sns' | 'step-functions'
|
||||
|
||||
const targetServiceDetails: Record<TargetService, {label: string; service: CloudServiceType; matches: (resource: CloudResource) => boolean}> = {
|
||||
lambda: {label: 'Lambda', service: 'serverless', matches: (resource) => resource.type === 'lambda'},
|
||||
sqs: {label: 'SQS', service: 'messaging', matches: (resource) => resource.type === 'queue' || resource.type === 'fifo-queue'},
|
||||
sns: {label: 'SNS', service: 'notifications', matches: (resource) => resource.type === 'topic'},
|
||||
'step-functions': {label: 'Step Functions', service: 'workflows', matches: (resource) => resource.type === 'state-machine'},
|
||||
}
|
||||
|
||||
export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventBridgeRulePanelProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [mode, setMode] = useState<'schedule' | 'event-pattern'>('schedule')
|
||||
const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)')
|
||||
const [eventPattern, setEventPattern] = useState('{\n "source": ["orders"]\n}')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [targetService, setTargetService] = useState<TargetService>('lambda')
|
||||
const [targetArn, setTargetArn] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<EventBridgeRuleOverview | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const targetQueries = {
|
||||
serverless: useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
|
||||
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||
}),
|
||||
messaging: useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'messaging'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'messaging', undefined, signal),
|
||||
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||
}),
|
||||
notifications: useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'notifications'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'notifications', undefined, signal),
|
||||
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||
}),
|
||||
workflows: useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'workflows'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'workflows', undefined, signal),
|
||||
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||
}),
|
||||
}
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ['eventbridge-rules', cloud, resource?.id],
|
||||
queryFn: ({signal}) => listEventBridgeRules(cloud, resource!.id, signal),
|
||||
enabled: cloud === 'aws' && resource?.service === 'events' && resource.type === 'event-bus' && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setMode('schedule')
|
||||
setScheduleExpression('rate(5 minutes)')
|
||||
setEventPattern('{\n "source": ["orders"]\n}')
|
||||
setEnabled(true)
|
||||
setTargetService('lambda')
|
||||
setTargetArn('')
|
||||
setDeleteTarget(null)
|
||||
}, [resource?.id])
|
||||
|
||||
const resourcesByTargetService = (Object.keys(targetServiceDetails) as TargetService[]).reduce<Record<TargetService, CloudResource[]>>((resources, service) => {
|
||||
const details = targetServiceDetails[service]
|
||||
const sourceResources = details.service === 'serverless'
|
||||
? targetQueries.serverless.data ?? []
|
||||
: details.service === 'messaging'
|
||||
? targetQueries.messaging.data ?? []
|
||||
: details.service === 'notifications'
|
||||
? targetQueries.notifications.data ?? []
|
||||
: targetQueries.workflows.data ?? []
|
||||
resources[service] = sourceResources.filter((candidate) => details.matches(candidate) && typeof candidate.metadata.arn === 'string' && candidate.metadata.arn.length > 0)
|
||||
return resources
|
||||
}, {lambda: [], sqs: [], sns: [], 'step-functions': []})
|
||||
const availableTargetServices = (Object.keys(targetServiceDetails) as TargetService[]).filter((service) => resourcesByTargetService[service].length > 0)
|
||||
const targetResources = resourcesByTargetService[targetService]
|
||||
const selectedTarget = targetResources.find((candidate) => candidate.metadata.arn === targetArn)
|
||||
const patternValid = mode !== 'event-pattern' || isJsonObject(eventPattern)
|
||||
|
||||
useEffect(() => {
|
||||
if (availableTargetServices.length > 0 && !availableTargetServices.includes(targetService)) {
|
||||
setTargetService(availableTargetServices[0])
|
||||
setTargetArn('')
|
||||
}
|
||||
}, [availableTargetServices, targetService])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createEventBridgeRule(cloud, resource!.id, {
|
||||
name: name.trim(),
|
||||
...(mode === 'schedule' ? {scheduleExpression: scheduleExpression.trim()} : {eventPattern: eventPattern.trim()}),
|
||||
state: enabled ? 'ENABLED' : 'DISABLED',
|
||||
...(description.trim() ? {description: description.trim()} : {}),
|
||||
targets: [{id: targetId(selectedTarget!), arn: targetArn}],
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setTargetArn('')
|
||||
void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]})
|
||||
},
|
||||
})
|
||||
const canCreate = Boolean(name.trim() && targetArn && (mode === 'schedule' ? scheduleExpression.trim() : eventPattern.trim()) && patternValid) && !createMutation.isPending
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (rule: EventBridgeRuleOverview) => deleteEventBridgeRule(cloud, resource!.id, rule.name),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null)
|
||||
void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]})
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || resource.service !== 'events' || resource.type !== 'event-bus') return null
|
||||
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">EventBridge Rules</p>
|
||||
<h3><Radio size={15}/> Add rule to {resource.name}</h3>
|
||||
<p className="muted compact-text">Route a schedule or matching events from this event bus to one existing target.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canCreate) createMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Rule name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="hourly-report" required/></label>
|
||||
<label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Generate the hourly report"/></label>
|
||||
<label className="dynamic-field"><span>Mode</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as 'schedule' | 'event-pattern')}><option value="schedule">Schedule</option><option value="event-pattern">Event pattern</option></select></label>
|
||||
{mode === 'schedule' ? <label className="dynamic-field"><span>Schedule expression</span><input className="input" value={scheduleExpression} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)" required/><small>Use a rate or cron expression.</small></label> : <label className="dynamic-field"><span>Event pattern (JSON)</span><textarea className="textarea code-textarea small" value={eventPattern} onChange={(event) => setEventPattern(event.target.value)} spellCheck={false} required/>{!patternValid && <small className="error-text">Event pattern must be a JSON object.</small>}</label>}
|
||||
<label className="dynamic-field"><span>Target service</span><select className="input" value={targetService} onChange={(event) => { setTargetService(event.target.value as TargetService); setTargetArn('') }}>{availableTargetServices.length === 0 ? <option value={targetService}>{targetServiceDetails[targetService].label}</option> : availableTargetServices.map((service) => <option key={service} value={service}>{targetServiceDetails[service].label}</option>)}</select><small>{availableTargetServices.length === 0 ? 'No supported EventBridge targets are available.' : 'Only services with discoverable resources are listed.'}</small></label>
|
||||
<label className="dynamic-field"><span>Target resource</span><select className="input" value={targetArn} onChange={(event) => setTargetArn(event.target.value)} required><option value="">Select a {targetServiceDetails[targetService].label} resource</option>{targetResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{targetResources.length === 0 ? `No ${targetServiceDetails[targetService].label} resources with an ARN are available.` : 'Uses the resource ARN returned by the cloud API.'}</small></label>
|
||||
<label className="dynamic-field"><span>State</span><span><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)}/> Enabled</span></label>
|
||||
<div className="apigateway-route-explorer"><p className="eyebrow">Reviewed creation plan</p><p className="muted compact-text">Create EventBridge rule <code>{name.trim() || 'your-rule'}</code> on bus <code>{resource.name}</code> with {mode === 'schedule' ? <code>{scheduleExpression.trim() || 'your schedule'}</code> : 'the supplied event pattern'}, then attach existing {targetServiceDetails[targetService].label} target <code>{selectedTarget?.name || 'your-target'}</code>. No Lambda, SQS queue, SNS topic, or Step Functions resources will be created.</p></div>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Creating rule' : 'Create rule'}</button>
|
||||
</form>
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to create EventBridge rule.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Rules</p><h3><CalendarClock size={15}/> Rules and targets</h3></div></div>
|
||||
{rulesQuery.isLoading && <p className="muted compact-text">Loading EventBridge rules...</p>}
|
||||
{rulesQuery.isError && <p className="error-text compact-text">{rulesQuery.error instanceof Error ? rulesQuery.error.message : 'Unable to load EventBridge rules.'}</p>}
|
||||
{rulesQuery.data?.length === 0 && <p className="muted compact-text">No rules configured for this event bus.</p>}
|
||||
{rulesQuery.data?.map((rule) => <div className="apigateway-route-item" key={rule.name}><div className="apigateway-route-select"><span className="apigateway-route-method">{rule.state}</span><code>{rule.name}</code><small>{rule.scheduleExpression ?? rule.eventPattern ?? 'No pattern'}{rule.description ? ` · ${rule.description}` : ''}</small>{rule.targets.map((target) => <small key={target.id}>Target {target.id}: <code>{target.arn}</code></small>)}</div>{deleteTarget?.name === rule.name ? <div className="apigateway-route-confirm"><span>Delete rule?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(rule)}>Delete</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Delete rule ${rule.name}`} onClick={() => setDeleteTarget(rule)}><Trash2 size={13}/></button>}</div>)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function isJsonObject(value: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(resource: CloudResource): string {
|
||||
return resource.id.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 64) || 'target'
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {fireEvent, render, screen, waitFor} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {beforeEach, expect, test, vi} from "vitest";
|
||||
import {SnsTopicPanel} from "./SnsTopicPanel";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createSnsSubscription: vi.fn(),
|
||||
deleteSnsSubscription: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
listSnsSubscriptions: vi.fn(),
|
||||
publishNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/cloudProxyClient", () => apiMocks);
|
||||
|
||||
const topic: CloudResource = {
|
||||
id: "arn:aws:sns:us-east-1:000000000000:orders",
|
||||
name: "orders",
|
||||
cloud: "aws",
|
||||
service: "notifications",
|
||||
type: "topic",
|
||||
region: null,
|
||||
createdAt: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SnsTopicPanel cloud="aws" resource={topic} runtimeReachable />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(apiMocks).forEach((mock) => mock.mockReset());
|
||||
apiMocks.listCloudResources.mockResolvedValue([]);
|
||||
apiMocks.listSnsSubscriptions.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
test("creates a Lambda subscription from the selected resource ARN", async () => {
|
||||
const lambda: CloudResource = {...topic, id: "worker", name: "worker", service: "serverless", type: "lambda", metadata: {arn: "arn:aws:lambda:us-east-1:000000000000:function:worker"}};
|
||||
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === "serverless" ? [lambda] : []));
|
||||
apiMocks.createSnsSubscription.mockResolvedValue({arn: "subscription-1"});
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByRole("option", {name: "worker"});
|
||||
await user.selectOptions(screen.getByLabelText(/lambda endpoint/i), String(lambda.metadata.arn));
|
||||
await user.click(screen.getByRole("button", {name: "Subscribe"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.createSnsSubscription).toHaveBeenCalledWith("aws", topic.id, {
|
||||
protocol: "lambda", endpoint: lambda.metadata.arn,
|
||||
}));
|
||||
});
|
||||
|
||||
test("creates a manual HTTPS subscription and explains confirmation", async () => {
|
||||
apiMocks.createSnsSubscription.mockResolvedValue({arn: "subscription-2"});
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Protocol"), "https");
|
||||
await user.type(screen.getByLabelText(/endpoint/i), "https://example.com/hooks");
|
||||
expect(screen.getByText("SNS may require endpoint confirmation before delivery can begin.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", {name: "Subscribe"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.createSnsSubscription).toHaveBeenCalledWith("aws", topic.id, {
|
||||
protocol: "https", endpoint: "https://example.com/hooks",
|
||||
}));
|
||||
});
|
||||
|
||||
test("shows returned subscription confirmation and filter policy, then confirms unsubscribe", async () => {
|
||||
const subscription = {arn: "arn:aws:sns:us-east-1:000000000000:orders:sub-1", protocol: "https", endpoint: "https://example.com/hooks", status: "PendingConfirmation", filterPolicy: '{"eventType":["created"]}'};
|
||||
apiMocks.listSnsSubscriptions.mockResolvedValue([subscription]);
|
||||
apiMocks.deleteSnsSubscription.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("Confirmation: PendingConfirmation")).toBeVisible();
|
||||
expect(screen.getByText(/Filter policy:/i)).toHaveTextContent(subscription.filterPolicy);
|
||||
await user.click(screen.getByRole("button", {name: /unsubscribe https/i}));
|
||||
expect(screen.getByText("Unsubscribe this endpoint?")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", {name: "Unsubscribe"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.deleteSnsSubscription).toHaveBeenCalledWith("aws", topic.id, subscription.arn));
|
||||
});
|
||||
|
||||
test("publishes a topic message with optional subject and attributes", async () => {
|
||||
apiMocks.publishNotification.mockResolvedValue({messageId: "message-123"});
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.type(screen.getByLabelText(/subject/i), "Order created");
|
||||
await user.type(screen.getByLabelText(/^message$/i), "New order");
|
||||
fireEvent.change(screen.getByLabelText(/message attributes/i), {target: {value: '{"eventType":"order.created"}'}});
|
||||
await user.click(screen.getByRole("button", {name: "Publish"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.publishNotification).toHaveBeenCalledWith("aws", topic.id, {
|
||||
subject: "Order created",
|
||||
message: "New order",
|
||||
messageAttributes: {eventType: "order.created"},
|
||||
}));
|
||||
expect(await screen.findByText("message-123")).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects message attributes that are not string-valued JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.type(screen.getByLabelText(/^message$/i), "New order");
|
||||
fireEvent.change(screen.getByLabelText(/message attributes/i), {target: {value: '{"attempt":1}'}});
|
||||
await user.click(screen.getByRole("button", {name: "Publish"}));
|
||||
|
||||
expect(screen.getByText("Message attribute values must be strings.")).toBeVisible();
|
||||
expect(apiMocks.publishNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {BellRing, Loader2, Plus, Send, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createSnsSubscription, deleteSnsSubscription, listCloudResources, listSnsSubscriptions, publishNotification, type PublishNotificationResult, type SnsSubscriptionOverview} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
interface SnsTopicPanelProps {
|
||||
cloud: CloudProvider;
|
||||
resource?: CloudResource;
|
||||
runtimeReachable: boolean;
|
||||
}
|
||||
|
||||
type SubscriptionProtocol = "lambda" | "sqs" | "http" | "https" | "email" | "email-json" | "sms";
|
||||
|
||||
const resourceProtocols: Record<"lambda" | "sqs", {label: string; service: "serverless" | "messaging"; matches: (candidate: CloudResource) => boolean}> = {
|
||||
lambda: {label: "Lambda", service: "serverless", matches: (candidate) => candidate.type === "lambda"},
|
||||
sqs: {label: "SQS", service: "messaging", matches: (candidate) => candidate.type === "queue" || candidate.type === "fifo-queue"},
|
||||
};
|
||||
|
||||
export function SnsTopicPanel({cloud, resource, runtimeReachable}: SnsTopicPanelProps) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [attributesText, setAttributesText] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<PublishNotificationResult | null>(null);
|
||||
const [protocol, setProtocol] = useState<SubscriptionProtocol>("lambda");
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<SnsSubscriptionOverview | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isTopic = resource?.service === "notifications" && resource.type === "topic";
|
||||
const subscriptionsEnabled = Boolean(isTopic && cloud === "aws" && runtimeReachable);
|
||||
const lambdaResourcesQuery = useQuery({
|
||||
queryKey: ["cloud-resources", cloud, "serverless"],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, "serverless", undefined, signal),
|
||||
enabled: subscriptionsEnabled,
|
||||
});
|
||||
const sqsResourcesQuery = useQuery({
|
||||
queryKey: ["cloud-resources", cloud, "messaging"],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, "messaging", undefined, signal),
|
||||
enabled: subscriptionsEnabled,
|
||||
});
|
||||
const subscriptionsQuery = useQuery({
|
||||
queryKey: ["sns-subscriptions", cloud, resource?.id],
|
||||
queryFn: ({signal}) => listSnsSubscriptions(cloud, resource!.id, signal),
|
||||
enabled: subscriptionsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
setAttributesText("");
|
||||
setValidationError(null);
|
||||
setResult(null);
|
||||
setProtocol("lambda");
|
||||
setEndpoint("");
|
||||
setDeleteTarget(null);
|
||||
}, [resource?.id]);
|
||||
|
||||
const canPublish = Boolean(isTopic && runtimeReachable && message.trim());
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (input: {message: string; subject?: string; messageAttributes?: Record<string, string>}) =>
|
||||
publishNotification(cloud, resource!.id, input),
|
||||
onSuccess: setResult,
|
||||
});
|
||||
const createSubscriptionMutation = useMutation({
|
||||
mutationFn: () => createSnsSubscription(cloud, resource!.id, {protocol, endpoint}),
|
||||
onSuccess: () => {
|
||||
setEndpoint("");
|
||||
void queryClient.invalidateQueries({queryKey: ["sns-subscriptions", cloud, resource?.id]});
|
||||
},
|
||||
});
|
||||
const deleteSubscriptionMutation = useMutation({
|
||||
mutationFn: (subscription: SnsSubscriptionOverview) => deleteSnsSubscription(cloud, resource!.id, subscription.arn!),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
void queryClient.invalidateQueries({queryKey: ["sns-subscriptions", cloud, resource?.id]});
|
||||
},
|
||||
});
|
||||
|
||||
const publish = () => {
|
||||
if (!canPublish) return;
|
||||
|
||||
try {
|
||||
const messageAttributes = parseMessageAttributes(attributesText);
|
||||
setValidationError(null);
|
||||
setResult(null);
|
||||
publishMutation.mutate({
|
||||
message,
|
||||
...(subject.trim() ? {subject: subject.trim()} : {}),
|
||||
...(messageAttributes ? {messageAttributes} : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : "Message attributes are invalid.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isTopic) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select an SNS topic</h3><p>Select a topic to publish a notification or manage subscriptions.</p></div></section>;
|
||||
}
|
||||
|
||||
const endpointResources = protocol === "lambda"
|
||||
? (lambdaResourcesQuery.data ?? []).filter((candidate) => resourceProtocols.lambda.matches(candidate) && typeof candidate.metadata.arn === "string" && candidate.metadata.arn.length > 0)
|
||||
: protocol === "sqs"
|
||||
? (sqsResourcesQuery.data ?? []).filter((candidate) => resourceProtocols.sqs.matches(candidate) && typeof candidate.metadata.arn === "string" && candidate.metadata.arn.length > 0)
|
||||
: [];
|
||||
const needsResourceSelector = protocol === "lambda" || protocol === "sqs";
|
||||
const resourcesLoading = protocol === "lambda" ? lambdaResourcesQuery.isLoading : sqsResourcesQuery.isLoading;
|
||||
const canCreateSubscription = subscriptionsEnabled && Boolean(endpoint) && !createSubscriptionMutation.isPending;
|
||||
|
||||
return <div className="sns-topic-workspace">
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div><p className="eyebrow">SNS Actions</p><h3><Send size={15} /> Publish message</h3><p className="muted compact-text">Publish directly to {resource.name || resource.id}.</p></div>
|
||||
<span className={`runtime-state ${runtimeReachable ? "ready" : "pending"}`}>{runtimeReachable ? "Ready" : "Runtime unavailable"}</span>
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<div className="sns-topic-summary"><span>Topic</span><code>{resource.name || resource.id}</code></div>
|
||||
<form className="inspector-action-form apigateway-route-form sns-publish-form" onSubmit={(event) => { event.preventDefault(); publish(); }}>
|
||||
<label className="dynamic-field" htmlFor="sns-publish-subject"><span>Subject (optional)</span><input id="sns-publish-subject" className="input" value={subject} onChange={(event) => setSubject(event.target.value)} /></label>
|
||||
<label className="dynamic-field dynamic-field--span" htmlFor="sns-publish-message"><span>Message</span><textarea id="sns-publish-message" className="textarea code-textarea" value={message} onChange={(event) => setMessage(event.target.value)} required spellCheck={false} placeholder="Write the notification body" /></label>
|
||||
<label className="dynamic-field dynamic-field--span" htmlFor="sns-publish-attributes"><span>Message attributes (optional JSON)</span><textarea id="sns-publish-attributes" className="textarea code-textarea small" value={attributesText} onChange={(event) => setAttributesText(event.target.value)} placeholder={'{"eventType":"order.created"}'} spellCheck={false} /><small>Use a JSON object with string values.</small></label>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canPublish || publishMutation.isPending}>{publishMutation.isPending ? <Loader2 size={13} className="spin" /> : <Send size={13} />}{publishMutation.isPending ? "Publishing" : "Publish"}</button>
|
||||
</form>
|
||||
{validationError && <p className="error-text compact-text">{validationError}</p>}
|
||||
{publishMutation.isError && <p className="error-text compact-text">{publishMutation.error instanceof Error ? publishMutation.error.message : "Unable to publish message."}</p>}
|
||||
{result && <div className="inspector-section"><p className="metric-label">MessageId</p><code>{result.messageId ?? "No MessageId returned"}</code></div>}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{cloud === "aws" && <section className="table-panel sns-subscriptions-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Subscriptions</p><h3><BellRing size={15} /> Subscribe an endpoint</h3></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canCreateSubscription) createSubscriptionMutation.mutate(); }}>
|
||||
<label className="dynamic-field" htmlFor="sns-subscription-protocol"><span>Protocol</span><select id="sns-subscription-protocol" className="input" value={protocol} onChange={(event) => { setProtocol(event.target.value as SubscriptionProtocol); setEndpoint(""); }}><option value="lambda">Lambda</option><option value="sqs">SQS</option><option value="http">HTTP</option><option value="https">HTTPS</option><option value="email">Email</option><option value="email-json">Email JSON</option><option value="sms">SMS</option></select></label>
|
||||
{needsResourceSelector ? <label className="dynamic-field" htmlFor="sns-subscription-resource-endpoint"><span>{resourceProtocols[protocol].label} endpoint</span><select id="sns-subscription-resource-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} required disabled={!subscriptionsEnabled}><option value="">Select a {resourceProtocols[protocol].label} resource</option>{endpointResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{resourcesLoading ? `Loading ${resourceProtocols[protocol].label} resources...` : endpointResources.length === 0 ? `No ${resourceProtocols[protocol].label} resources with an ARN are available.` : "Uses the resource ARN returned by the cloud API."}</small></label> : <label className="dynamic-field" htmlFor="sns-subscription-endpoint"><span>Endpoint</span><input id="sns-subscription-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} placeholder={manualEndpointPlaceholder(protocol)} required disabled={!subscriptionsEnabled} /><small>SNS may require endpoint confirmation before delivery can begin.</small></label>}
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreateSubscription}>{createSubscriptionMutation.isPending ? <Loader2 size={13} className="spin" /> : <Plus size={13} />}{createSubscriptionMutation.isPending ? "Subscribing" : "Subscribe"}</button>
|
||||
</form>
|
||||
{createSubscriptionMutation.isError && <p className="error-text compact-text">{createSubscriptionMutation.error instanceof Error ? createSubscriptionMutation.error.message : "Unable to create subscription."}</p>}
|
||||
{subscriptionsQuery.isLoading && <p className="muted compact-text">Loading subscriptions...</p>}
|
||||
{subscriptionsQuery.isError && <p className="error-text compact-text">{subscriptionsQuery.error instanceof Error ? subscriptionsQuery.error.message : "Unable to load subscriptions."}</p>}
|
||||
{subscriptionsQuery.data?.length === 0 && <p className="muted compact-text">No subscriptions configured for this topic.</p>}
|
||||
{subscriptionsQuery.data?.map((subscription) => <div className="apigateway-route-item" key={subscription.arn ?? `${subscription.protocol}:${subscription.endpoint}`}><div className="apigateway-route-select"><span className="apigateway-route-method">{subscription.protocol}</span><code>{subscription.endpoint}</code><small>Confirmation: {subscription.status}</small>{subscription.filterPolicy !== null && <small>Filter policy: <code>{subscription.filterPolicy}</code></small>}</div>{subscription.arn && (deleteTarget?.arn === subscription.arn ? <div className="apigateway-route-confirm"><span>Unsubscribe this endpoint?</span><button className="button danger compact" type="button" disabled={deleteSubscriptionMutation.isPending} onClick={() => deleteSubscriptionMutation.mutate(subscription)}>Unsubscribe</button><button className="button compact" type="button" disabled={deleteSubscriptionMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Unsubscribe ${subscription.endpoint}`} onClick={() => setDeleteTarget(subscription)}><Trash2 size={13} /></button>)}</div>)}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function manualEndpointPlaceholder(protocol: Exclude<SubscriptionProtocol, "lambda" | "sqs">): string {
|
||||
if (protocol === "email" || protocol === "email-json") return "alerts@example.com";
|
||||
if (protocol === "sms") return "+15551234567";
|
||||
return "https://example.com/notifications";
|
||||
}
|
||||
|
||||
function parseMessageAttributes(raw: string): Record<string, string> | undefined {
|
||||
if (!raw.trim()) return undefined;
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Message attributes must be valid JSON.");
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Message attributes must be a JSON object of string values.");
|
||||
}
|
||||
if (Object.values(value).some((entry) => typeof entry !== "string")) {
|
||||
throw new Error("Message attribute values must be strings.");
|
||||
}
|
||||
return Object.keys(value).length > 0 ? value as Record<string, string> : undefined;
|
||||
}
|
||||
@@ -2284,6 +2284,185 @@ pre,
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ami-catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ami-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ami-search svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.ami-search .input {
|
||||
width: 100%;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.ami-catalog-heading {
|
||||
color: var(--text-1);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ami-quick-start-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile {
|
||||
display: grid;
|
||||
grid-template-rows: 42px auto auto;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 12px 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--text-1);
|
||||
background: var(--raised);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile:hover {
|
||||
border-color: var(--link);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ami-quick-start-tile.selected {
|
||||
border-color: var(--link);
|
||||
box-shadow: inset 0 0 0 1px var(--link);
|
||||
}
|
||||
|
||||
.ami-quick-start-tile.unavailable {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile.unavailable:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.ami-quick-start-tile img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile span {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ami-browser-toggle {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ami-browser-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ami-browser-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 2px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ami-browser-item input {
|
||||
accent-color: var(--link);
|
||||
}
|
||||
|
||||
.ami-browser-item > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ami-browser-item b {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ami-browser-item code {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ami-quick-start-grid {
|
||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.ami-catalog {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ami-quick-start-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ami-browser-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ami-browser-item code {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.sg-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2581,6 +2760,43 @@ pre,
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.sns-topic-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sns-topic-workspace .table-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sns-topic-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sns-publish-form .dynamic-field--span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sns-publish-form .code-textarea {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.sns-publish-form .code-textarea.small {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sns-topic-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.apigateway-route-form .dynamic-field {
|
||||
grid-template-rows: 14px 30px minmax(15px, auto);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type KnownCloudServiceType =
|
||||
| 'networking'
|
||||
| 'serverless'
|
||||
| 'messaging'
|
||||
| 'notifications'
|
||||
| 'events'
|
||||
| 'secrets'
|
||||
| 'logs'
|
||||
|
||||
Reference in New Issue
Block a user