feat: EC2 UI polished + storage persisting

This commit is contained in:
Zakaria
2026-09-25 18:18:33 -04:00
parent 1b129e9dcf
commit 093afb9b2b
40 changed files with 2586 additions and 102 deletions
+65 -2
View File
@@ -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,
{
+5 -3
View File
@@ -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,
+259 -85
View File
@@ -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;
}
+216
View File
@@ -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);
}
+1
View File
@@ -9,6 +9,7 @@ export type KnownCloudServiceType =
| 'networking'
| 'serverless'
| 'messaging'
| 'notifications'
| 'events'
| 'secrets'
| 'logs'