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
@@ -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'
}