58 lines
9.4 KiB
TypeScript
58 lines
9.4 KiB
TypeScript
import {Copy, Play, Plus, ShieldCheck, Trash2} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {createCloudFormationChangeSet, deleteCloudFormationChangeSet, executeCloudFormationChangeSet, getCloudResource, listCloudFormationChangeSets, updateCloudFormationTerminationProtection} from '@/api/cloudProxyClient'
|
|
import {useState} from 'react'
|
|
import {CloudFormationTemplateUpload} from './CloudFormationTemplateUpload'
|
|
import type {CloudResource} from '@/types/resource'
|
|
|
|
export function CloudFormationStackPanel({resource}: {resource?: CloudResource}) {
|
|
if (!resource || resource.service !== 'iac' || resource.type !== 'stack') return null
|
|
return <CloudFormationStackPanelContent resource={resource}/>
|
|
}
|
|
|
|
function CloudFormationStackPanelContent({resource}: {resource: CloudResource}) {
|
|
const detail = useQuery({queryKey: ['cloudformation-stack-detail', resource.id], queryFn: ({signal}) => getCloudResource('aws', 'iac', resource.id, signal), refetchInterval: (query) => /(?:_IN_PROGRESS|_PENDING)$/.test(String(query.state.data?.status ?? resource.status ?? '')) ? 1000 : false})
|
|
const stack = detail.data ?? resource
|
|
const [changeSetName, setChangeSetName] = useState('')
|
|
const [templateBody, setTemplateBody] = useState(String(resource.metadata.templateBody ?? ''))
|
|
const [templateError, setTemplateError] = useState<string | null>(null)
|
|
const [changeSetParameters, setChangeSetParameters] = useState('{}')
|
|
const [changeSetCapabilities, setChangeSetCapabilities] = useState('')
|
|
const queryClient = useQueryClient()
|
|
const changeSets = useQuery({queryKey: ['cloudformation-change-sets', resource.id], queryFn: ({signal}) => listCloudFormationChangeSets('aws', resource.id, signal)})
|
|
const refresh = () => void queryClient.invalidateQueries({queryKey: ['cloudformation-change-sets', resource.id]})
|
|
const createChangeSet = useMutation({mutationFn: () => createCloudFormationChangeSet('aws', resource.id, {name: changeSetName.trim(), templateBody, parameters: JSON.parse(changeSetParameters) as Record<string, string>, capabilities: changeSetCapabilities.split(',').map((item) => item.trim()).filter(Boolean)}), onSuccess: refresh})
|
|
const executeChangeSet = useMutation({mutationFn: (name: string) => executeCloudFormationChangeSet('aws', resource.id, name), onSuccess: refresh})
|
|
const deleteChangeSet = useMutation({mutationFn: (name: string) => deleteCloudFormationChangeSet('aws', resource.id, name), onSuccess: refresh})
|
|
const protection = useMutation({mutationFn: (enabled: boolean) => updateCloudFormationTerminationProtection('aws', resource.id, enabled), onSuccess: () => void queryClient.invalidateQueries({queryKey: ['cloudformation-stack-detail', resource.id]})})
|
|
const outputs = array(stack.metadata.outputs)
|
|
const resources = array(stack.metadata.resources).map((item) => ({...item, consolePath: consolePath(String(item.resourceType ?? ''), String(item.physicalId ?? ''))}))
|
|
const events = array(stack.metadata.events)
|
|
const template = typeof stack.metadata.templateBody === 'string' ? stack.metadata.templateBody : null
|
|
return <section className="table-panel">
|
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Stack workspace</p><h3>{stack.name}</h3><p className="muted compact-text">Stack lifecycle is asynchronous. Refresh the stack list to observe status transitions.</p></div></div>
|
|
<StackTable title="Outputs" columns={['key', 'value', 'description']} rows={outputs}/>
|
|
<StackTable title="Resources" columns={['logicalId', 'physicalId', 'resourceType', 'status']} rows={resources}/>
|
|
<StackTable title="Recent events" columns={['timestamp', 'logicalId', 'status', 'reason']} rows={events}/>
|
|
<section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Stack actions</p><h3><ShieldCheck size={15}/> Termination protection: {stack.metadata.enableTerminationProtection === true ? 'Enabled' : 'Disabled'}</h3></div><button className="button compact" type="button" disabled={protection.isPending} onClick={() => protection.mutate(stack.metadata.enableTerminationProtection !== true)}>{stack.metadata.enableTerminationProtection === true ? 'Disable protection' : 'Enable protection'}</button></div><p className="muted compact-text">{stack.metadata.enableTerminationProtection === true ? 'Enabled: DeleteStack is blocked until this is disabled.' : 'Disabled: the stack can be deleted.'}</p></section>
|
|
<section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Change sets</p><h3>Preview and deploy</h3><p className="muted compact-text">Resource import is unavailable because Floci does not yet implement AWS resource adoption.</p></div></div><form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (changeSetName.trim() && templateBody.trim()) createChangeSet.mutate() }}><label className="dynamic-field"><span>Change set name</span><input className="input" value={changeSetName} onChange={(event) => setChangeSetName(event.target.value)} required/></label><label className="dynamic-field dynamic-field--span"><span>Proposed template</span><textarea className="textarea code-textarea" value={templateBody} onChange={(event) => setTemplateBody(event.target.value)} rows={10} required spellCheck={false}/></label><div><CloudFormationTemplateUpload onLoad={(template) => { setTemplateBody(template); setTemplateError(null) }} onError={setTemplateError}/></div><label className="dynamic-field dynamic-field--span"><span>Parameters (JSON)</span><textarea className="textarea code-textarea small" value={changeSetParameters} onChange={(event) => setChangeSetParameters(event.target.value)} spellCheck={false}/></label><label className="dynamic-field dynamic-field--span"><span>Capabilities</span><input className="input" value={changeSetCapabilities} onChange={(event) => setChangeSetCapabilities(event.target.value)} placeholder="CAPABILITY_IAM, CAPABILITY_NAMED_IAM"/></label>{templateError && <div className="form-error" role="alert">{templateError}</div>}<button className="button primary" type="submit" disabled={createChangeSet.isPending}><Plus size={13}/> Create change set</button></form>{changeSets.data?.map((changeSet) => <div className="apigateway-route-item" key={changeSet.id}><div className="apigateway-route-select"><code>{changeSet.name}</code><small>{changeSet.status} / {changeSet.executionStatus}</small>{changeSet.changes.map((change, index) => <small key={index}>{change.action} {change.resourceType} {change.logicalResourceId}</small>)}</div><button className="button compact" type="button" disabled={changeSet.status !== 'CREATE_COMPLETE' && changeSet.status !== 'AVAILABLE' || executeChangeSet.isPending} onClick={() => executeChangeSet.mutate(changeSet.name)}><Play size={13}/> Execute</button><button className="icon-btn" type="button" aria-label={`Delete change set ${changeSet.name}`} disabled={deleteChangeSet.isPending} onClick={() => deleteChangeSet.mutate(changeSet.name)}><Trash2 size={13}/></button></div>)}</section>
|
|
{template && <section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Submitted template</p><h3>Original template</h3></div><button className="button compact" type="button" onClick={() => void navigator.clipboard.writeText(template)}><Copy size={13}/> Copy</button></div><pre className="metadata-block">{template}</pre></section>}
|
|
</section>
|
|
}
|
|
|
|
function StackTable({title, columns, rows}: {title: string; columns: string[]; rows: Array<Record<string, unknown>>}) {
|
|
return <section className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">{title}</p><h3>{rows.length}</h3></div></div>{rows.length === 0 ? <p className="muted compact-text">No {title.toLowerCase()} reported.</p> : <div className="cosmos-items-table"><table className="table"><thead><tr>{columns.map((column) => <th key={column}>{label(column)}</th>)}</tr></thead><tbody>{rows.map((row, index) => <tr key={`${title}-${index}`}>{columns.map((column) => <td key={column}>{column === 'physicalId' && typeof row.consolePath === 'string' ? <a href={row.consolePath}><code>{string(row[column])}</code></a> : <code>{string(row[column])}</code>}</td>)}</tr>)}</tbody></table></div>}</section>
|
|
}
|
|
|
|
function array(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : [] }
|
|
function string(value: unknown): string { return value === null || value === undefined ? '-' : String(value) }
|
|
function label(value: string): string { return value.replace(/([A-Z])/g, ' $1').replace(/^./, (letter) => letter.toUpperCase()) }
|
|
function consolePath(resourceType: string, physicalId: string): string | null {
|
|
const parts = physicalId.split('/').filter(Boolean)
|
|
const name = parts[parts.length - 1] ?? physicalId
|
|
const service = resourceType === 'AWS::DynamoDB::Table' ? 'nosql' : resourceType === 'AWS::SQS::Queue' ? 'messaging' : resourceType === 'AWS::Lambda::Function' ? 'serverless' : resourceType === 'AWS::Events::EventBus' ? 'events' : resourceType === 'AWS::StepFunctions::StateMachine' ? 'workflows' : null
|
|
if (!service || !name) return null
|
|
const id = service === 'events' ? `event-bus:${name}` : name
|
|
return `/cloud-explorer/aws/${service}?resource=${encodeURIComponent(id)}`
|
|
}
|