feat: Step Function + hardning
This commit is contained in:
@@ -58,6 +58,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
|
||||
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
|
||||
interface DynamicResourceViewProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -947,6 +948,9 @@ export function DynamicResourceView({
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "workflows" && cloud === "aws" && (
|
||||
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{editingResource && schema.updateFields && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest'
|
||||
import {WorkflowExecutionDebugger} from '@/components/WorkflowExecutionDebugger'
|
||||
import {describeWorkflowExecution, getWorkflowExecutionHistory, listWorkflowExecutions, startWorkflowExecution, stopWorkflowExecution} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => ({
|
||||
describeWorkflowExecution: vi.fn(),
|
||||
getWorkflowExecutionHistory: vi.fn(),
|
||||
listWorkflowExecutions: vi.fn(),
|
||||
startWorkflowExecution: vi.fn(),
|
||||
stopWorkflowExecution: vi.fn(),
|
||||
}))
|
||||
|
||||
const machine: CloudResource = {id: 'arn:aws:states:us-east-1:000000000000:stateMachine:orders', name: 'orders', cloud: 'aws', service: 'workflows', type: 'state-machine', region: 'us-east-1', createdAt: null, metadata: {machineType: 'STANDARD'}}
|
||||
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><WorkflowExecutionDebugger resource={machine} runtimeReachable={true}/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('WorkflowExecutionDebugger', () => {
|
||||
test('starts an execution and automatically aggregates paged history in backend order', async () => {
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(startWorkflowExecution).mockResolvedValue({executionArn, startDate: '2026-07-28T10:01:00.000Z'})
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id, input: '{}', output: '{"ok":true}'})
|
||||
vi.mocked(getWorkflowExecutionHistory).mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted', timestamp: '2026-07-28T10:01:00.000Z'}], nextToken: 'next'}).mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted', timestamp: '2026-07-28T10:01:00.000Z'}, {id: 2, type: 'ExecutionSucceeded'}]})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionSucceeded') === true)).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(1, machine.id, executionArn, undefined, expect.any(AbortSignal))
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(2, machine.id, executionArn, 'next', expect.any(AbortSignal))
|
||||
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toHaveLength(1)
|
||||
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
|
||||
await user.click(screen.getByRole('button', {name: 'Start execution'}))
|
||||
await waitFor(() => expect(startWorkflowExecution).toHaveBeenCalledWith(machine.id, {input: '{"orderId":"1"}'}))
|
||||
})
|
||||
|
||||
test('confirms stopping only a running execution', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
|
||||
vi.mocked(getWorkflowExecutionHistory).mockResolvedValue({events: []})
|
||||
vi.mocked(stopWorkflowExecution).mockResolvedValue()
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
await user.click(await screen.findByRole('button', {name: 'Stop execution'}))
|
||||
await waitFor(() => expect(stopWorkflowExecution).toHaveBeenCalledWith(machine.id, executionArn))
|
||||
})
|
||||
|
||||
test('keeps loaded events visible when a later history page cannot load', async () => {
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
|
||||
vi.mocked(getWorkflowExecutionHistory).mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted'}], nextToken: 'next'}).mockRejectedValueOnce(new Error('Unable to load the next history page'))
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
|
||||
expect(await screen.findByText('Unable to load the next history page')).toBeInTheDocument()
|
||||
expect(screen.getByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('polls only the trailing history page for a running execution and appends new events', async () => {
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
|
||||
vi.mocked(getWorkflowExecutionHistory)
|
||||
.mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted'}], nextToken: 'page-2'})
|
||||
.mockResolvedValueOnce({events: [{id: 2, type: 'TaskStateEntered'}]})
|
||||
.mockResolvedValueOnce({events: [{id: 2, type: 'TaskStateEntered'}, {id: 3, type: 'TaskSucceeded'}]})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskStateEntered') === true)).toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskSucceeded') === true, {}, {timeout: 3_000})).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(3, machine.id, executionArn, 'page-2', expect.any(AbortSignal))
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
test('marks history incomplete at the explicit page ceiling while retaining loaded progress', async () => {
|
||||
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
|
||||
let page = 0
|
||||
vi.mocked(getWorkflowExecutionHistory).mockImplementation(async () => {
|
||||
page++
|
||||
return {events: [{id: page, type: 'Event'}], nextToken: 'more'}
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByText('run-1'))
|
||||
|
||||
expect(await screen.findByText('History incomplete: reached the 10-page limit.')).toBeInTheDocument()
|
||||
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(10)
|
||||
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('Event') === true)).toHaveLength(10)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Play, RefreshCw, Square} from 'lucide-react'
|
||||
import {describeWorkflowExecution, getWorkflowExecutionHistory, listWorkflowExecutions, startWorkflowExecution, stopWorkflowExecution, type WorkflowExecutionDetail} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface WorkflowExecutionDebuggerProps {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const POLL_MS = 2_000
|
||||
const MAX_HISTORY_PAGES = 10
|
||||
const MAX_HISTORY_EVENTS = 10_000
|
||||
|
||||
interface ExecutionHistory {
|
||||
events: Array<Record<string, unknown>>
|
||||
loading: boolean
|
||||
incomplete?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export function WorkflowExecutionDebugger({resource, runtimeReachable}: WorkflowExecutionDebuggerProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [input, setInput] = useState('{}')
|
||||
const [name, setName] = useState('')
|
||||
const [selectedExecutionArn, setSelectedExecutionArn] = useState<string>()
|
||||
const isStandard = resource?.metadata.machineType === 'STANDARD'
|
||||
const enabled = Boolean(resource && runtimeReachable && isStandard)
|
||||
const executionKey = ['workflow-executions', resource?.id]
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedExecutionArn(undefined)
|
||||
}, [resource?.id])
|
||||
|
||||
const executions = useQuery({
|
||||
queryKey: executionKey,
|
||||
queryFn: ({signal}) => listWorkflowExecutions(resource!.id, signal),
|
||||
enabled,
|
||||
refetchInterval: (query) => query.state.data?.some((execution) => execution.status === 'RUNNING') ? POLL_MS : false,
|
||||
})
|
||||
const detail = useQuery({
|
||||
queryKey: ['workflow-execution', resource?.id, selectedExecutionArn],
|
||||
queryFn: ({signal}) => describeWorkflowExecution(resource!.id, selectedExecutionArn!, signal),
|
||||
enabled: enabled && Boolean(selectedExecutionArn),
|
||||
refetchInterval: (query) => query.state.data?.status === 'RUNNING' ? POLL_MS : false,
|
||||
})
|
||||
const history = useExecutionHistory(resource?.id, selectedExecutionArn, enabled && Boolean(detail.data), detail.data?.status === 'RUNNING')
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: () => startWorkflowExecution(resource!.id, {input, ...(name.trim() ? {name: name.trim()} : {})}),
|
||||
onSuccess: (execution) => {
|
||||
setSelectedExecutionArn(execution.executionArn)
|
||||
void queryClient.invalidateQueries({queryKey: executionKey})
|
||||
},
|
||||
})
|
||||
const stop = useMutation({
|
||||
mutationFn: () => stopWorkflowExecution(resource!.id, selectedExecutionArn!),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({queryKey: executionKey})
|
||||
void queryClient.invalidateQueries({queryKey: ['workflow-execution', resource?.id, selectedExecutionArn]})
|
||||
},
|
||||
})
|
||||
if (!resource) return null
|
||||
if (!isStandard) return <section className="table-panel"><p className="muted compact-text">Execution debugging is available for Standard state machines only.</p></section>
|
||||
|
||||
const running = detail.data?.status === 'RUNNING'
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Execution debugger</p><h3>Run and inspect executions</h3></div><button className="button" type="button" disabled={!enabled || executions.isFetching} onClick={() => executions.refetch()}><RefreshCw size={13}/>{executions.isFetching ? 'Refreshing' : 'Refresh'}</button></div>
|
||||
<div className="input-row" style={{alignItems: 'end'}}>
|
||||
<label className="field"><span>JSON input</span><textarea className="input" aria-label="JSON input" rows={4} value={input} onChange={(event) => setInput(event.target.value)}/></label>
|
||||
<label className="field"><span>Execution name (optional)</span><input className="input" aria-label="Execution name (optional)" value={name} onChange={(event) => setName(event.target.value)}/></label>
|
||||
<button className="button" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Start execution'}</button>
|
||||
</div>
|
||||
<p className="muted compact-text">Execution input, output, and history can contain sensitive data.</p>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start execution.')}</p>}
|
||||
{executions.isError && <p className="error-text compact-text">{message(executions.error, 'Unable to load executions.')}</p>}
|
||||
<table className="table"><thead><tr><th>Name</th><th>Status</th><th>Started</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.executionArn} onClick={() => setSelectedExecutionArn(execution.executionArn)} style={{cursor: 'pointer'}}><td><code>{execution.name}</code></td><td>{execution.status}</td><td>{execution.startDate}</td></tr>)}</tbody></table>
|
||||
{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}
|
||||
{selectedExecutionArn && <ExecutionDetail detail={detail.data} loading={detail.isLoading} error={detail.error} running={running} stopping={stop.isPending} onStop={() => { if (window.confirm('Stop this running execution?')) stop.mutate() }} stopError={stop.error} events={history.events} loadingHistory={history.loading} historyError={history.error} historyIncomplete={history.incomplete}/>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function ExecutionDetail({detail, loading, error, running, stopping, onStop, stopError, events, loadingHistory, historyError, historyIncomplete}: {detail?: WorkflowExecutionDetail; loading: boolean; error: unknown; running: boolean; stopping: boolean; onStop: () => void; stopError: unknown; events: Array<Record<string, unknown>>; loadingHistory: boolean; historyError: unknown; historyIncomplete?: string}) {
|
||||
if (loading) return <p className="muted compact-text">Loading execution...</p>
|
||||
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load execution.')}</p>
|
||||
if (!detail) return null
|
||||
return <div style={{marginTop: 16}}><div className="dynamic-stage-header"><div><h3>{detail.name} <span className="muted">{detail.status}</span></h3></div>{running && <button className="button danger" type="button" disabled={stopping} onClick={onStop}><Square size={13}/>{stopping ? 'Stopping' : 'Stop execution'}</button>}</div>
|
||||
{Boolean(stopError) && <p className="error-text compact-text">{message(stopError, 'Unable to stop execution.')}</p>}
|
||||
<ExecutionData label="Input" value={detail.input}/><ExecutionData label="Output" value={detail.output}/>{detail.error && <ExecutionData label="Error" value={detail.error}/>} {detail.cause && <ExecutionData label="Cause" value={detail.cause}/>}
|
||||
<h4>Timeline</h4>{loadingHistory && <p className="muted compact-text">Loading history...</p>}{historyIncomplete && <p className="muted compact-text">History incomplete: {historyIncomplete}</p>}{Boolean(historyError) && <p className="error-text compact-text">{message(historyError, 'Unable to load complete execution history.')}</p>}
|
||||
{events.map((event, index) => <details key={`${String(event.id ?? index)}-${index}`}><summary>{String(event.timestamp ?? '')} {String(event.type ?? 'Event')}</summary><pre>{JSON.stringify(event, null, 2)}</pre></details>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
function useExecutionHistory(stateMachineArn: string | undefined, executionArn: string | undefined, enabled: boolean, running: boolean): ExecutionHistory {
|
||||
const [history, setHistory] = useState<ExecutionHistory>({events: [], loading: false})
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !stateMachineArn || !executionArn) {
|
||||
setHistory({events: [], loading: false})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const events: Array<Record<string, unknown>> = []
|
||||
const eventKeys = new Set<string>()
|
||||
let pages = 0
|
||||
let trailingPageToken: string | undefined
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const publish = (loading: boolean, incomplete?: string, error?: unknown) => {
|
||||
if (!controller.signal.aborted) setHistory({events: [...events], loading, ...(incomplete ? {incomplete} : {}), ...(error ? {error} : {})})
|
||||
}
|
||||
const addPage = (page: {events: Array<Record<string, unknown>>; nextToken?: string}): boolean => {
|
||||
for (const event of page.events) {
|
||||
const key = event.id === undefined ? JSON.stringify(event) : `id:${String(event.id)}`
|
||||
if (!eventKeys.has(key)) {
|
||||
if (events.length === MAX_HISTORY_EVENTS) return false
|
||||
eventKeys.add(key)
|
||||
events.push(event)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
const limitMessage = (pageLimitReached: boolean, eventLimitReached: boolean) => {
|
||||
if (pageLimitReached && eventLimitReached) return `reached the ${MAX_HISTORY_PAGES}-page and ${MAX_HISTORY_EVENTS}-event limits.`
|
||||
if (pageLimitReached) return `reached the ${MAX_HISTORY_PAGES}-page limit.`
|
||||
return `reached the ${MAX_HISTORY_EVENTS}-event limit.`
|
||||
}
|
||||
const loadNewPages = async (nextToken: string | undefined, poll: boolean): Promise<boolean> => {
|
||||
let token = nextToken
|
||||
do {
|
||||
if (!poll && pages === MAX_HISTORY_PAGES) {
|
||||
publish(false, limitMessage())
|
||||
return false
|
||||
}
|
||||
const page = await getWorkflowExecutionHistory(stateMachineArn, executionArn, token, controller.signal)
|
||||
if (controller.signal.aborted) return false
|
||||
if (!poll) pages++
|
||||
trailingPageToken = token
|
||||
const withinEventLimit = addPage(page)
|
||||
const pageLimitReached = pages === MAX_HISTORY_PAGES && Boolean(page.nextToken)
|
||||
const eventLimitReached = !withinEventLimit || (events.length === MAX_HISTORY_EVENTS && Boolean(page.nextToken))
|
||||
if (pageLimitReached || eventLimitReached) {
|
||||
publish(false, limitMessage(pageLimitReached, eventLimitReached))
|
||||
return false
|
||||
}
|
||||
publish(true)
|
||||
token = page.nextToken
|
||||
poll = false
|
||||
} while (token)
|
||||
return true
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
publish(true)
|
||||
if (!await loadNewPages(undefined, false)) return
|
||||
publish(false)
|
||||
if (running) {
|
||||
pollTimer = setTimeout(() => void poll(), POLL_MS)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) publish(false, undefined, error)
|
||||
}
|
||||
}
|
||||
const poll = async () => {
|
||||
try {
|
||||
publish(true)
|
||||
if (!await loadNewPages(trailingPageToken, true)) return
|
||||
publish(false)
|
||||
pollTimer = setTimeout(() => void poll(), POLL_MS)
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) publish(false, undefined, error)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
}
|
||||
}, [enabled, executionArn, running, stateMachineArn])
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
function ExecutionData({label, value}: {label: string; value?: string}) {
|
||||
if (value === undefined) return null
|
||||
return <div><h4>{label}</h4><pre>{value}</pre></div>
|
||||
}
|
||||
|
||||
function message(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
Reference in New Issue
Block a user