feat: Step Function + small fixes + more services + color matching the services
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 639 KiB |
+9
-6
@@ -1,11 +1,14 @@
|
|||||||
# Stage 1 — build frontend
|
# Stage 1 — build frontend
|
||||||
FROM node:20-alpine AS frontend-build
|
FROM node:20-alpine AS frontend-build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/frontend/package.json ./
|
RUN corepack enable && corepack prepare pnpm@9.2.0 --activate
|
||||||
RUN npm install
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY packages/frontend/ .
|
COPY packages/api/package.json packages/api/package.json
|
||||||
COPY OS_img/ /OS_img/
|
COPY packages/frontend/package.json packages/frontend/package.json
|
||||||
RUN npm run build
|
RUN pnpm install --frozen-lockfile --filter @floci/frontend
|
||||||
|
COPY packages/frontend/ packages/frontend/
|
||||||
|
COPY OS_img/ OS_img/
|
||||||
|
RUN pnpm --filter @floci/frontend run build
|
||||||
|
|
||||||
# Stage 2 — compile API to a self-contained binary
|
# Stage 2 — compile API to a self-contained binary
|
||||||
FROM oven/bun:1-alpine AS api-build
|
FROM oven/bun:1-alpine AS api-build
|
||||||
@@ -20,7 +23,7 @@ FROM alpine:3
|
|||||||
RUN apk add --no-cache ca-certificates libstdc++
|
RUN apk add --no-cache ca-certificates libstdc++
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-build /app/server ./server
|
COPY --from=api-build /app/server ./server
|
||||||
COPY --from=frontend-build /app/dist ./public
|
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
||||||
ENV PORT=4500
|
ENV PORT=4500
|
||||||
EXPOSE 4500
|
EXPOSE 4500
|
||||||
CMD ["./server"]
|
CMD ["./server"]
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
ListStateMachinesCommand,
|
ListStateMachinesCommand,
|
||||||
StartExecutionCommand,
|
StartExecutionCommand,
|
||||||
StopExecutionCommand,
|
StopExecutionCommand,
|
||||||
|
UpdateStateMachineCommand,
|
||||||
type SFNClient,
|
type SFNClient,
|
||||||
} from '@aws-sdk/client-sfn'
|
} from '@aws-sdk/client-sfn'
|
||||||
import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter'
|
import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter'
|
||||||
|
import {mapAwsSdkError} from './awsErrors'
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
type SendResult = Record<string, unknown>
|
type SendResult = Record<string, unknown>
|
||||||
@@ -177,13 +179,32 @@ describe('AwsStepFunctionsAdapter', () => {
|
|||||||
expect(resource.id).toBe(ARN)
|
expect(resource.id).toBe(ARN)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('requires the fields the schema marks required', async () => {
|
test('passes an explicit definition to the SDK exactly as received', async () => {
|
||||||
|
const definition = ` ${DEFINITION}\n`
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
|
||||||
|
await new AwsStepFunctionsAdapter(client).create({values: {...validValues, definition}})
|
||||||
|
|
||||||
|
expect((sent[0] as CreateStateMachineCommand).input.definition).toBe(definition)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a name-only workflow with a local role and valid initial ASL', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
await new AwsStepFunctionsAdapter(client).create({values: {name: 'orders'}})
|
||||||
|
|
||||||
|
expect((sent[0] as CreateStateMachineCommand).input).toEqual(expect.objectContaining({
|
||||||
|
name: 'orders',
|
||||||
|
roleArn: 'arn:aws:iam::000000000000:role/floci-step-functions',
|
||||||
|
definition: JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires the name the schema marks required', async () => {
|
||||||
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
|
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
|
||||||
|
|
||||||
const cases: Array<[Record<string, unknown>, string]> = [
|
const cases: Array<[Record<string, unknown>, string]> = [
|
||||||
[{}, 'name is required'],
|
[{}, 'name is required'],
|
||||||
[{name: 'orders'}, 'roleArn is required'],
|
[{name: 'orders', definition: ''}, 'definition is required'],
|
||||||
[{name: 'orders', roleArn: ROLE}, 'definition is required'],
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const [values, message] of cases) {
|
for (const [values, message] of cases) {
|
||||||
@@ -199,16 +220,22 @@ describe('AwsStepFunctionsAdapter', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects a definition missing StartAt or States', async () => {
|
test('rejects a definition without a string StartAt or object States', async () => {
|
||||||
// Valid JSON is not a valid state machine. The runtime rejects it, but with
|
// Valid JSON is not a valid state machine. The runtime rejects it, but with
|
||||||
// a parser message; naming the missing field is more actionable.
|
// a parser message; naming the missing field is more actionable.
|
||||||
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
|
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
|
||||||
|
|
||||||
await expect(adapter.create({values: {...validValues, definition: '{"States":{}}'}})).rejects.toThrow(
|
await expect(adapter.create({values: {...validValues, definition: '{"States":{}}'}})).rejects.toThrow(
|
||||||
new ValidationError('definition must contain StartAt'),
|
new ValidationError('definition must contain StartAt as a string'),
|
||||||
)
|
)
|
||||||
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done"}'}})).rejects.toThrow(
|
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done"}'}})).rejects.toThrow(
|
||||||
new ValidationError('definition must contain States'),
|
new ValidationError('definition must contain States as an object'),
|
||||||
|
)
|
||||||
|
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":1,"States":{}}'}})).rejects.toThrow(
|
||||||
|
new ValidationError('definition must contain StartAt as a string'),
|
||||||
|
)
|
||||||
|
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done","States":[]}'}})).rejects.toThrow(
|
||||||
|
new ValidationError('definition must contain States as an object'),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -227,6 +254,43 @@ describe('AwsStepFunctionsAdapter', () => {
|
|||||||
expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN)
|
expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('updates only the definition and reads the state machine back', async () => {
|
||||||
|
const updatedDefinition = '{\n "StartAt": "Pass",\n "States": {"Pass": {"Type": "Pass", "End": true}}\n}'
|
||||||
|
const {client, sent} = stubSfn((command) => {
|
||||||
|
if (command instanceof UpdateStateMachineCommand) return {updateDate: new Date()}
|
||||||
|
if (command instanceof DescribeStateMachineCommand) return {...described, definition: updatedDefinition}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resource = await new AwsStepFunctionsAdapter(client).update(ARN, {values: {definition: updatedDefinition}})
|
||||||
|
|
||||||
|
expect(sent[0]).toBeInstanceOf(UpdateStateMachineCommand)
|
||||||
|
expect((sent[0] as UpdateStateMachineCommand).input).toEqual({stateMachineArn: ARN, definition: updatedDefinition})
|
||||||
|
expect(sent[1]).toBeInstanceOf(DescribeStateMachineCommand)
|
||||||
|
expect(resource.metadata.definition).toBe(updatedDefinition)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves update SDK failures for the shared AWS error mapper', async () => {
|
||||||
|
const providerError = Object.assign(new Error('definition is invalid'), {
|
||||||
|
name: 'InvalidDefinition',
|
||||||
|
$fault: 'client',
|
||||||
|
$metadata: {httpStatusCode: 400},
|
||||||
|
})
|
||||||
|
const {client} = stubSfn(() => {
|
||||||
|
throw providerError
|
||||||
|
})
|
||||||
|
|
||||||
|
let caught: unknown
|
||||||
|
try {
|
||||||
|
await new AwsStepFunctionsAdapter(client).update(ARN, {values: {definition: DEFINITION}})
|
||||||
|
} catch (error) {
|
||||||
|
caught = error
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBe(providerError)
|
||||||
|
expect(mapAwsSdkError(caught)).toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
test('uses AWS execution commands with input data and history pagination', async () => {
|
test('uses AWS execution commands with input data and history pagination', async () => {
|
||||||
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
|
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
|
||||||
const {client, sent} = stubSfn((command) => {
|
const {client, sent} = stubSfn((command) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ListStateMachinesCommand,
|
ListStateMachinesCommand,
|
||||||
StartExecutionCommand,
|
StartExecutionCommand,
|
||||||
StopExecutionCommand,
|
StopExecutionCommand,
|
||||||
|
UpdateStateMachineCommand,
|
||||||
type SFNClient,
|
type SFNClient,
|
||||||
type StateMachineListItem,
|
type StateMachineListItem,
|
||||||
} from '@aws-sdk/client-sfn'
|
} from '@aws-sdk/client-sfn'
|
||||||
@@ -20,6 +21,7 @@ import type {
|
|||||||
CreateResourceInput,
|
CreateResourceInput,
|
||||||
ResourceQuery,
|
ResourceQuery,
|
||||||
ServiceSchema,
|
ServiceSchema,
|
||||||
|
UpdateResourceInput,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,11 +93,11 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
|
|||||||
'name must be up to 80 characters of letters, digits, hyphens and underscores',
|
'name must be up to 80 characters of letters, digits, hyphens and underscores',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const roleArn = requiredString(input.values.roleArn, 'roleArn')
|
const roleArn = optionalString(input.values.roleArn) ?? 'arn:aws:iam::000000000000:role/floci-step-functions'
|
||||||
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) {
|
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) {
|
||||||
throw new ValidationError('roleArn must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role')
|
throw new ValidationError('roleArn must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role')
|
||||||
}
|
}
|
||||||
const definition = requiredDefinition(input.values.definition)
|
const definition = input.values.definition === undefined ? initialDefinition() : requiredDefinition(input.values.definition)
|
||||||
|
|
||||||
const res = await this.sfn.send(new CreateStateMachineCommand({name, definition, roleArn}))
|
const res = await this.sfn.send(new CreateStateMachineCommand({name, definition, roleArn}))
|
||||||
const arn = res.stateMachineArn
|
const arn = res.stateMachineArn
|
||||||
@@ -111,6 +113,15 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
|
|||||||
await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id}))
|
await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
|
||||||
|
const definition = requiredDefinition(input.values.definition)
|
||||||
|
await this.sfn.send(new UpdateStateMachineCommand({stateMachineArn: id, definition}))
|
||||||
|
|
||||||
|
const updated = await this.get(id)
|
||||||
|
if (!updated) throw new RuntimeError(`State machine ${id} was updated but could not be read back`)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
async startExecution(stateMachineArn: string, input: StartWorkflowExecutionInput): Promise<{executionArn: string; startDate: Date}> {
|
async startExecution(stateMachineArn: string, input: StartWorkflowExecutionInput): Promise<{executionArn: string; startDate: Date}> {
|
||||||
const result = await this.sfn.send(new StartExecutionCommand({
|
const result = await this.sfn.send(new StartExecutionCommand({
|
||||||
stateMachineArn,
|
stateMachineArn,
|
||||||
@@ -220,21 +231,24 @@ function filterBySearch(resources: CloudResource[], search?: string): CloudResou
|
|||||||
* with a JSON parser message; naming the missing field is more actionable.
|
* with a JSON parser message; naming the missing field is more actionable.
|
||||||
*/
|
*/
|
||||||
function requiredDefinition(value: unknown): string {
|
function requiredDefinition(value: unknown): string {
|
||||||
const raw = requiredString(value, 'definition')
|
if (typeof value !== 'string') throw new ValidationError('definition must be a string')
|
||||||
|
if (!value.trim()) throw new ValidationError('definition is required')
|
||||||
|
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw)
|
parsed = JSON.parse(value)
|
||||||
} catch {
|
} catch {
|
||||||
throw new ValidationError('definition must be valid JSON')
|
throw new ValidationError('definition must be valid JSON')
|
||||||
}
|
}
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
throw new ValidationError('definition must be a JSON object')
|
throw new ValidationError('definition must be a JSON object')
|
||||||
}
|
}
|
||||||
for (const field of ['StartAt', 'States'] as const) {
|
const definition = parsed as Record<string, unknown>
|
||||||
if (!(field in parsed)) throw new ValidationError(`definition must contain ${field}`)
|
if (typeof definition.StartAt !== 'string') throw new ValidationError('definition must contain StartAt as a string')
|
||||||
|
if (typeof definition.States !== 'object' || definition.States === null || Array.isArray(definition.States)) {
|
||||||
|
throw new ValidationError('definition must contain States as an object')
|
||||||
}
|
}
|
||||||
return raw
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function validExecutionInput(value: string): string {
|
function validExecutionInput(value: string): string {
|
||||||
@@ -259,3 +273,12 @@ function requiredString(value: unknown, field: string): string {
|
|||||||
if (!trimmed) throw new ValidationError(`${field} is required`)
|
if (!trimmed) throw new ValidationError(`${field} is required`)
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined
|
||||||
|
return requiredString(value, 'roleArn')
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialDefinition(): string {
|
||||||
|
return JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {awsWorkflowsSchema} from './workflowsSchema'
|
||||||
|
|
||||||
|
describe('awsWorkflowsSchema', () => {
|
||||||
|
test('requires a name and truthfully exposes the optional ASL definition', () => {
|
||||||
|
const schema = awsWorkflowsSchema()
|
||||||
|
|
||||||
|
expect(schema.fields.map((field) => field.name)).toEqual(['name', 'definition'])
|
||||||
|
expect(schema.fields[0]?.required).toBe(true)
|
||||||
|
expect(schema.fields[1]).toMatchObject({
|
||||||
|
name: 'definition',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -40,29 +40,17 @@ export function awsWorkflowsSchema(): ServiceSchema {
|
|||||||
message: 'Up to 80 characters: letters, digits, hyphens and underscores.',
|
message: 'Up to 80 characters: letters, digits, hyphens and underscores.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'roleArn',
|
|
||||||
label: 'Execution Role ARN',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
group: 'Required',
|
|
||||||
description: 'The IAM role the state machine assumes when it runs.',
|
|
||||||
validation: {
|
|
||||||
pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$',
|
|
||||||
message: 'Must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'definition',
|
name: 'definition',
|
||||||
label: 'Definition (Amazon States Language)',
|
label: 'ASL Definition',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
required: true,
|
required: false,
|
||||||
|
group: 'Optional',
|
||||||
span: true,
|
span: true,
|
||||||
group: 'Required',
|
description: 'Complete Amazon States Language JSON. When omitted, a valid starter workflow is created.',
|
||||||
description: 'JSON with StartAt and States, e.g. {"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
actions: ['list', 'create', 'delete', 'inspect'],
|
actions: ['list', 'create', 'update', 'delete', 'inspect'],
|
||||||
capabilities: {
|
capabilities: {
|
||||||
resourceActions: [
|
resourceActions: [
|
||||||
{name: 'list', label: 'List state machines', enabled: true, status: 'available', runtimeRequired: true},
|
{name: 'list', label: 'List state machines', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
@@ -74,6 +62,7 @@ export function awsWorkflowsSchema(): ServiceSchema {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
runtimeRequired: true,
|
runtimeRequired: true,
|
||||||
},
|
},
|
||||||
|
{name: 'update', label: 'Update definition', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
{name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true},
|
{name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-devtools": "^5.101.2",
|
"@tanstack/react-query-devtools": "^5.101.2",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
|
|||||||
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||||
|
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +18,7 @@ export default function App() {
|
|||||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
||||||
|
<Route path="/cloud-explorer/aws/workflows/:resourceId/studio" element={<WorkflowStudioPage/>}/>
|
||||||
<Route path="/secretsmanager" element={<SecretsManagerPage/>}/>
|
<Route path="/secretsmanager" element={<SecretsManagerPage/>}/>
|
||||||
<Route path="*" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="*" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
getCloudResource,
|
||||||
getCloudServiceStatus,
|
getCloudServiceStatus,
|
||||||
getCloudStatus,
|
getCloudStatus,
|
||||||
listCloudResources,
|
listCloudResources,
|
||||||
@@ -23,6 +24,8 @@ export const cloudQueryKeys = {
|
|||||||
['cloud-service-status', cloud, service] as const,
|
['cloud-service-status', cloud, service] as const,
|
||||||
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||||
['cloud-console-resources', cloud, service] as const,
|
['cloud-console-resources', cloud, service] as const,
|
||||||
|
resource: (cloud: CloudProvider, service: CloudServiceType, id: string) =>
|
||||||
|
['cloud-resource', cloud, service, id] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCloudsQuery() {
|
export function useCloudsQuery() {
|
||||||
@@ -82,6 +85,21 @@ export function useCloudConsoleResourcesQuery({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCloudResourceQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
service: CloudServiceType,
|
||||||
|
id?: string,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: cloudQueryKeys.resource(cloud, service, id ?? ''),
|
||||||
|
queryFn: ({signal}) => getCloudResource(cloud, service, id!, signal),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
// A list refresh returns summary rows. Keep the inspected resource cached
|
||||||
|
// until the user selects a different state machine.
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function hasAvailableService(services: CloudServiceDescriptor[] | undefined, service: CloudServiceType): boolean {
|
function hasAvailableService(services: CloudServiceDescriptor[] | undefined, service: CloudServiceType): boolean {
|
||||||
return services?.some((item) => item.service === service && item.availability === 'available') ?? false
|
return services?.some((item) => item.service === service && item.availability === 'available') ?? false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
|||||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||||
|
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||||
|
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||||
|
import {workflowStudioPath} from "@/lib/workflowStudio";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
interface DynamicResourceViewProps {
|
interface DynamicResourceViewProps {
|
||||||
cloud: CloudProvider;
|
cloud: CloudProvider;
|
||||||
@@ -71,6 +75,11 @@ interface DynamicResourceViewProps {
|
|||||||
onOpenInfo: () => void;
|
onOpenInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreateMutationVariables {
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
destination?: WorkflowCreationDestination;
|
||||||
|
}
|
||||||
|
|
||||||
export function DynamicResourceView({
|
export function DynamicResourceView({
|
||||||
cloud,
|
cloud,
|
||||||
service,
|
service,
|
||||||
@@ -80,6 +89,7 @@ export function DynamicResourceView({
|
|||||||
statusLoading = false,
|
statusLoading = false,
|
||||||
onOpenInfo,
|
onOpenInfo,
|
||||||
}: DynamicResourceViewProps) {
|
}: DynamicResourceViewProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [state, setState] = useState("");
|
const [state, setState] = useState("");
|
||||||
@@ -166,7 +176,7 @@ export function DynamicResourceView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: async (values: Record<string, unknown>) => {
|
mutationFn: async ({values}: CreateMutationVariables) => {
|
||||||
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
|
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
|
||||||
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
|
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
|
||||||
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
|
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
|
||||||
@@ -254,12 +264,16 @@ export function DynamicResourceView({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (resource) => {
|
onSuccess: (resource, variables) => {
|
||||||
setSelected(resource);
|
setSelected(resource);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
void qc.invalidateQueries({
|
void qc.invalidateQueries({
|
||||||
queryKey: ["cloud-resources", cloud, service],
|
queryKey: ["cloud-resources", cloud, service],
|
||||||
});
|
});
|
||||||
|
if (cloud === "aws" && service === "workflows" && variables.destination === "studio") {
|
||||||
|
const destinationPath = workflowCreationDestinationPath(variables.destination, resource.id);
|
||||||
|
if (destinationPath) navigate(destinationPath);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -774,14 +788,21 @@ export function DynamicResourceView({
|
|||||||
isSubmitting={createMut.isPending}
|
isSubmitting={createMut.isPending}
|
||||||
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
onSubmit={(values) => createMut.mutate({values})}
|
||||||
/>
|
/>
|
||||||
) : service === "loadbalancing" && cloud === "aws" ? (
|
) : service === "loadbalancing" && cloud === "aws" ? (
|
||||||
<LoadBalancerPanel
|
<LoadBalancerPanel
|
||||||
isSubmitting={createMut.isPending}
|
isSubmitting={createMut.isPending}
|
||||||
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
onSubmit={(values) => createMut.mutate({values})}
|
||||||
|
/>
|
||||||
|
) : service === "workflows" && cloud === "aws" ? (
|
||||||
|
<WorkflowCreatePanel
|
||||||
|
isSubmitting={createMut.isPending}
|
||||||
|
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onSubmit={(submission) => createMut.mutate(submission)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DynamicFormRenderer
|
<DynamicFormRenderer
|
||||||
@@ -796,7 +817,7 @@ export function DynamicResourceView({
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
onSubmit={(values) => createMut.mutate({values})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -840,6 +861,8 @@ export function DynamicResourceView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
{activeSelected && !showDatabaseSnapshots && (
|
{activeSelected && !showDatabaseSnapshots && (
|
||||||
|
<div>
|
||||||
|
{cloud === "aws" && service === "workflows" && <button className="button compact workflow-open-studio" type="button" onClick={() => navigate(workflowStudioPath(activeSelected.id))}>Open Studio</button>}
|
||||||
<ResourceInspector
|
<ResourceInspector
|
||||||
resource={activeSelected}
|
resource={activeSelected}
|
||||||
object={selectedObject}
|
object={selectedObject}
|
||||||
@@ -847,6 +870,7 @@ export function DynamicResourceView({
|
|||||||
runtimeReachable={canUseRuntime}
|
runtimeReachable={canUseRuntime}
|
||||||
serviceName={schema.displayName}
|
serviceName={schema.displayName}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{service === "storage" && (
|
{service === "storage" && (
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {fireEvent, render, screen} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {describe, expect, test, vi} from 'vitest'
|
||||||
|
import {WorkflowCreatePanel} from './WorkflowCreatePanel'
|
||||||
|
|
||||||
|
function renderPanel(onSubmit = vi.fn()) {
|
||||||
|
render(<WorkflowCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
return onSubmit
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorkflowCreatePanel', () => {
|
||||||
|
test('offers explicit Studio and ASL creation modes', () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', {name: /workflow studio/i})).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(screen.getByRole('button', {name: /asl definition/i})).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
expect(screen.queryByLabelText(/asl definition/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submits a name-only Studio creation targeting Studio', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSubmit = renderPanel()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/state machine name/i), ' orders ')
|
||||||
|
await user.click(screen.getByRole('button', {name: /create and open studio/i}))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({values: {name: 'orders'}, destination: 'studio'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submits the exact ASL text targeting Explorer', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSubmit = renderPanel()
|
||||||
|
const definition = ' {\n "StartAt": "Done",\n "States": {"Done": {"Type": "Succeed"}}\n}\n'
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /asl definition/i}))
|
||||||
|
await user.type(screen.getByLabelText(/state machine name/i), 'orders')
|
||||||
|
fireEvent.change(screen.getByLabelText(/asl definition/i), {target: {value: definition}})
|
||||||
|
await user.click(screen.getByRole('button', {name: /create state machine/i}))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({values: {name: 'orders', definition}, destination: 'explorer'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['', 'ASL definition is required.'],
|
||||||
|
['not json', 'ASL definition must be valid JSON.'],
|
||||||
|
['[]', 'ASL definition must be a JSON object.'],
|
||||||
|
['{"StartAt":1,"States":{}}', 'ASL definition must include StartAt as a string.'],
|
||||||
|
['{"StartAt":"Done","States":[]}', 'ASL definition must include States as an object.'],
|
||||||
|
])('rejects invalid ASL definition %s', async (definition, message) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSubmit = renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /asl definition/i}))
|
||||||
|
await user.type(screen.getByLabelText(/state machine name/i), 'orders')
|
||||||
|
if (definition) fireEvent.change(screen.getByLabelText(/asl definition/i), {target: {value: definition}})
|
||||||
|
await user.click(screen.getByRole('button', {name: /create state machine/i}))
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(message)
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {Braces, PanelsTopLeft} from 'lucide-react'
|
||||||
|
import {validateAslDefinition, workflowCreateSubmission, type WorkflowCreateSubmission} from '@/lib/workflowCreate'
|
||||||
|
|
||||||
|
type CreationMode = 'studio' | 'asl'
|
||||||
|
|
||||||
|
export function WorkflowCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: {isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (submission: WorkflowCreateSubmission) => void}) {
|
||||||
|
const [mode, setMode] = useState<CreationMode>('studio')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [definition, setDefinition] = useState('')
|
||||||
|
const [definitionError, setDefinitionError] = useState<string | null>(null)
|
||||||
|
const validName = /^[a-zA-Z0-9_-]{1,80}$/.test(name.trim())
|
||||||
|
|
||||||
|
return <form className="dynamic-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!validName) return
|
||||||
|
if (mode === 'asl') {
|
||||||
|
const error = validateAslDefinition(definition)
|
||||||
|
setDefinitionError(error)
|
||||||
|
if (error) return
|
||||||
|
onSubmit(workflowCreateSubmission(name, 'explorer', definition))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit(workflowCreateSubmission(name, 'studio'))
|
||||||
|
}}>
|
||||||
|
<div className="workflow-create-modes" role="group" aria-label="Creation mode">
|
||||||
|
<button className={`workflow-create-mode${mode === 'studio' ? ' active' : ''}`} type="button" aria-pressed={mode === 'studio'} disabled={isSubmitting} onClick={() => { setMode('studio'); setDefinitionError(null) }}>
|
||||||
|
<PanelsTopLeft size={18}/><span><strong>Workflow Studio</strong><small>Create a valid starter workflow, then design it visually.</small></span>
|
||||||
|
</button>
|
||||||
|
<button className={`workflow-create-mode${mode === 'asl' ? ' active' : ''}`} type="button" aria-pressed={mode === 'asl'} disabled={isSubmitting} onClick={() => setMode('asl')}>
|
||||||
|
<Braces size={18}/><span><strong>ASL definition</strong><small>Create directly from a complete Amazon States Language document.</small></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="dynamic-field dynamic-field--span">
|
||||||
|
<span>State Machine Name<em className="field-required">*</em></span>
|
||||||
|
<input className="input" autoFocus value={name} disabled={isSubmitting} onChange={(event) => setName(event.target.value)} maxLength={80} required pattern="[a-zA-Z0-9_-]+" aria-describedby="workflow-name-help"/>
|
||||||
|
<small id="workflow-name-help">Up to 80 characters: letters, digits, hyphens, and underscores.</small>
|
||||||
|
</label>
|
||||||
|
{mode === 'asl' && <label className="dynamic-field dynamic-field--span">
|
||||||
|
<span>ASL Definition<em className="field-required">*</em></span>
|
||||||
|
<textarea className={`textarea workflow-definition-input${definitionError ? ' invalid' : ''}`} value={definition} disabled={isSubmitting} onChange={(event) => { setDefinition(event.target.value); setDefinitionError(null) }} rows={14} spellCheck={false} aria-describedby="workflow-definition-help workflow-definition-error"/>
|
||||||
|
<small id="workflow-definition-help">Enter the complete JSON definition. It must contain a string <code>StartAt</code> and an object <code>States</code>.</small>
|
||||||
|
{definitionError && <small className="field-error" id="workflow-definition-error" role="alert">{definitionError}</small>}
|
||||||
|
</label>}
|
||||||
|
{submitError && <p className="error-text" role="alert">{submitError}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button>
|
||||||
|
<button className="button primary" type="submit" disabled={!validName || isSubmitting}>{isSubmitting ? 'Creating' : mode === 'studio' ? 'Create and open Studio' : 'Create state machine'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import {afterEach, describe, expect, test, vi} from 'vitest'
|
import {afterEach, describe, expect, test, vi} from 'vitest'
|
||||||
import {WorkflowExecutionDebugger} from '@/components/WorkflowExecutionDebugger'
|
import {WorkflowExecutionDebugger} from '@/components/WorkflowExecutionDebugger'
|
||||||
@@ -45,9 +45,24 @@ describe('WorkflowExecutionDebugger', () => {
|
|||||||
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
|
||||||
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
|
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
|
||||||
await user.click(screen.getByRole('button', {name: 'Start execution'}))
|
await user.click(screen.getByRole('button', {name: 'Start execution'}))
|
||||||
|
expect(screen.getByRole('alertdialog')).toHaveTextContent('{"orderId":"1"}')
|
||||||
|
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', {name: 'Start execution'}))
|
||||||
await waitFor(() => expect(startWorkflowExecution).toHaveBeenCalledWith(machine.id, {input: '{"orderId":"1"}'}))
|
await waitFor(() => expect(startWorkflowExecution).toHaveBeenCalledWith(machine.id, {input: '{"orderId":"1"}'}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('does not start until the inline confirmation is accepted', async () => {
|
||||||
|
vi.mocked(listWorkflowExecutions).mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Start execution'}))
|
||||||
|
expect(screen.getByRole('alertdialog')).toHaveTextContent('may invoke an unavailable Lambda')
|
||||||
|
expect(startWorkflowExecution).not.toHaveBeenCalled()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Cancel'}))
|
||||||
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||||
|
expect(startWorkflowExecution).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
test('confirms stopping only a running execution', async () => {
|
test('confirms stopping only a running execution', async () => {
|
||||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
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(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z'}])
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function WorkflowExecutionDebugger({resource, runtimeReachable}: Workflow
|
|||||||
const [input, setInput] = useState('{}')
|
const [input, setInput] = useState('{}')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [selectedExecutionArn, setSelectedExecutionArn] = useState<string>()
|
const [selectedExecutionArn, setSelectedExecutionArn] = useState<string>()
|
||||||
|
const [startConfirmation, setStartConfirmation] = useState(false)
|
||||||
const isStandard = resource?.metadata.machineType === 'STANDARD'
|
const isStandard = resource?.metadata.machineType === 'STANDARD'
|
||||||
const enabled = Boolean(resource && runtimeReachable && isStandard)
|
const enabled = Boolean(resource && runtimeReachable && isStandard)
|
||||||
const executionKey = ['workflow-executions', resource?.id]
|
const executionKey = ['workflow-executions', resource?.id]
|
||||||
@@ -70,8 +71,12 @@ export function WorkflowExecutionDebugger({resource, runtimeReachable}: Workflow
|
|||||||
<div className="input-row" style={{alignItems: 'end'}}>
|
<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>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>
|
<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>
|
<button className="button" type="button" disabled={!enabled || start.isPending} onClick={() => setStartConfirmation(true)}><Play size={13}/>{start.isPending ? 'Starting' : 'Start execution'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{startConfirmation && <div className="workflow-confirmation" role="alertdialog" aria-labelledby="workflow-start-title">
|
||||||
|
<strong id="workflow-start-title">Start this execution?</strong><span>This execution may invoke an unavailable Lambda. Name: {name.trim() || 'generated by AWS'}. Input: {input}</span>
|
||||||
|
<div><button className="button compact" type="button" disabled={start.isPending} onClick={() => { setStartConfirmation(false); start.mutate() }}>Start execution</button><button className="button compact" type="button" disabled={start.isPending} onClick={() => setStartConfirmation(false)}>Cancel</button></div>
|
||||||
|
</div>}
|
||||||
<p className="muted compact-text">Execution input, output, and history can contain sensitive data.</p>
|
<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>}
|
{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>}
|
{executions.isError && <p className="error-text compact-text">{message(executions.error, 'Unable to load executions.')}</p>}
|
||||||
@@ -132,7 +137,7 @@ function useExecutionHistory(stateMachineArn: string | undefined, executionArn:
|
|||||||
let token = nextToken
|
let token = nextToken
|
||||||
do {
|
do {
|
||||||
if (!poll && pages === MAX_HISTORY_PAGES) {
|
if (!poll && pages === MAX_HISTORY_PAGES) {
|
||||||
publish(false, limitMessage())
|
publish(false, limitMessage(true, false))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const page = await getWorkflowExecutionHistory(stateMachineArn, executionArn, token, controller.signal)
|
const page = await getWorkflowExecutionHistory(stateMachineArn, executionArn, token, controller.signal)
|
||||||
|
|||||||
@@ -0,0 +1,804 @@
|
|||||||
|
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, describe, expect, test, vi} from 'vitest'
|
||||||
|
import type {ComponentType, MouseEvent, ReactNode} from 'react'
|
||||||
|
import {buildFlowGraph, deleteStateInScope, insertRootState, renameStateInScope, spliceLambdaIntoEdge, spliceTaskIntoEdge, WorkflowStudio} from '@/components/WorkflowStudio'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const {getCloudResource, listCloudResources, updateCloudResource} = vi.hoisted(() => ({getCloudResource: vi.fn(), listCloudResources: vi.fn(), updateCloudResource: vi.fn()}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => ({getCloudResource, listCloudResources, updateCloudResource}))
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
Background: () => null,
|
||||||
|
BaseEdge: () => null,
|
||||||
|
Controls: () => null,
|
||||||
|
EdgeLabelRenderer: ({children}: {children: ReactNode}) => children,
|
||||||
|
getSmoothStepPath: () => ['M 0 0 L 100 100', 50, 50],
|
||||||
|
Handle: () => null,
|
||||||
|
MarkerType: {ArrowClosed: 'arrowclosed'},
|
||||||
|
Position: {Top: 'top', Bottom: 'bottom', Right: 'right'},
|
||||||
|
ReactFlow: ({nodes, edges, nodeTypes, edgeTypes, onNodeClick}: {nodes: Array<{id: string; type?: string; data: Record<string, unknown>}>; edges: Array<{id: string; type?: string; data?: Record<string, unknown>}>; nodeTypes: Record<string, ComponentType<{data: Record<string, unknown>}>>; edgeTypes: Record<string, ComponentType<Record<string, unknown>>>; onNodeClick: (event: MouseEvent, node: {data: Record<string, unknown>}) => void}) => <div>{nodes.map((node) => {
|
||||||
|
const NodeType = node.type ? nodeTypes[node.type] : undefined
|
||||||
|
if (node.type === 'placeholder' && NodeType) return <div key={node.id} onClick={(event) => onNodeClick(event, node)}><NodeType data={node.data}/></div>
|
||||||
|
return NodeType ? <div key={node.id}><button type="button" onClick={(event) => onNodeClick(event, node)}>{String(node.data.label)}</button><NodeType data={node.data}/></div> : <button key={node.id} type="button" onClick={(event) => onNodeClick(event, node)}>{String(node.data.label)}</button>
|
||||||
|
})}{edges.map((edge) => {
|
||||||
|
const EdgeType = edge.type ? edgeTypes[edge.type] : undefined
|
||||||
|
const edgeProps = {id: edge.id, sourceX: 0, sourceY: 0, targetX: 100, targetY: 100, sourcePosition: 'bottom', targetPosition: 'top', data: edge.data} as Record<string, unknown>
|
||||||
|
return EdgeType ? <EdgeType key={edge.id} {...edgeProps}/> : null
|
||||||
|
})}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const definition = JSON.stringify({
|
||||||
|
StartAt: 'Work',
|
||||||
|
States: {
|
||||||
|
Work: {Type: 'Task', Resource: 'arn:aws:lambda:local', Retry: [{ErrorEquals: ['States.ALL'], UnknownRetryField: {kept: true}}], Catch: [{ErrorEquals: ['States.ALL'], Next: 'Failed', UnknownCatchField: {kept: true}}], Next: 'Decision', UnknownTaskField: {kept: true}},
|
||||||
|
DirectMissing: {Type: 'Task', Resource: 'arn:aws:lambda:us-east-1:000000000000:function:missing', Next: 'Done', UnknownTaskField: {kept: true}},
|
||||||
|
IntegrationAttached: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'arn:aws:lambda:local'}, Next: 'Done'},
|
||||||
|
IntegrationMissing: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:missing-integration', Payload: {keep: true}}, Next: 'Done'},
|
||||||
|
PublishNotice: {Type: 'Task', Resource: 'arn:aws:states:::sns:publish', Parameters: {TopicArn: 'arn:aws:sns:us-east-1:000000000000:old-topic', Message: 'keep'}, Next: 'Done'},
|
||||||
|
NonLambda: {Type: 'Task', Resource: 'arn:aws:states:::dynamodb:putItem', Next: 'Done'},
|
||||||
|
NoResource: {Type: 'Task', Next: 'Done'},
|
||||||
|
Decision: {Type: 'Choice', Choices: [{Variable: '$.approved', BooleanEquals: true, Next: 'Done'}], Default: 'Failed', Custom: ['unchanged']},
|
||||||
|
PassThrough: {Type: 'Pass', Next: 'WaitHere'},
|
||||||
|
WaitHere: {Type: 'Wait', Seconds: 3, Next: 'Done'},
|
||||||
|
Done: {Type: 'Succeed'},
|
||||||
|
Failed: {Type: 'Fail'},
|
||||||
|
ParallelWork: {Type: 'Parallel', Branches: [{StartAt: 'BranchPass', States: {BranchPass: {Type: 'Pass', End: true}}}]},
|
||||||
|
MapWork: {Type: 'Map', Iterator: {StartAt: 'ItemPass', States: {ItemPass: {Type: 'Pass', End: true}}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resource: 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', definition},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStudio(selected = resource) {
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return {queryClient, ...render(<QueryClientProvider client={queryClient}><WorkflowStudio resource={selected}/></QueryClientProvider>)}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorkflowStudio', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getCloudResource.mockReset()
|
||||||
|
listCloudResources.mockReset()
|
||||||
|
updateCloudResource.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps supported ASL states, top-to-bottom boundaries, choice labels, retry markers, and directional catch edges', () => {
|
||||||
|
const graph = buildFlowGraph(JSON.parse(definition) as Record<string, unknown>, new Set(['arn:aws:lambda:local']))
|
||||||
|
|
||||||
|
expect(graph.nodes.map((node) => node.data.stateType)).toEqual(expect.arrayContaining(['Task', 'Choice', 'Pass', 'Parallel', 'Map', 'Wait', 'Succeed', 'Fail']))
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'Work')?.data.retry).toBe(true)
|
||||||
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({label: '$.approved BooleanEquals true'}),
|
||||||
|
expect.objectContaining({label: 'Default'}),
|
||||||
|
expect.objectContaining({label: 'Catch', className: 'workflow-catch-edge'}),
|
||||||
|
]))
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'BranchPass')?.parentId).toContain('ParallelWork')
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'ItemPass')?.parentId).toContain('MapWork')
|
||||||
|
expect(graph.nodes.map((node) => node.data.label)).toEqual(expect.arrayContaining(['Start', 'End']))
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'Work')?.position.y).toBeGreaterThan(graph.nodes.find((node) => node.data.label === 'Start')?.position.y ?? 0)
|
||||||
|
expect(graph.edges.every((edge) => edge.markerEnd)).toBe(true)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'DirectMissing')?.data.unattached).toBe(true)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'Work')?.data.unattached).toBe(false)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'IntegrationAttached')?.data.unattached).toBe(false)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'IntegrationMissing')?.data.unattached).toBe(true)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'NonLambda')?.data.unattached).toBe(false)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'NoResource')?.data.unattached).toBe(false)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'Decision')?.data.choiceBranches).toEqual(['Rule #1', 'Default'])
|
||||||
|
expect(graph.edges.find((edge) => edge.label === '$.approved BooleanEquals true')).toMatchObject({sourceHandle: 'choice-0'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders transient start-to-guidance-to-end edges for an empty States object', () => {
|
||||||
|
const graph = buildFlowGraph({StartAt: 'Initial', States: {}})
|
||||||
|
|
||||||
|
expect(graph.nodes.map((node) => node.id)).toEqual(expect.arrayContaining(['workflow-start', 'workflow-empty-guidance', 'workflow-end']))
|
||||||
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({source: 'workflow-start', target: 'workflow-empty-guidance'}),
|
||||||
|
expect.objectContaining({source: 'workflow-empty-guidance', target: 'workflow-end'}),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connects each terminal state to End', () => {
|
||||||
|
const graph = buildFlowGraph({StartAt: 'Done', States: {Done: {Type: 'Succeed'}, Failed: {Type: 'Fail'}}})
|
||||||
|
|
||||||
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({source: 'states/Done', target: 'workflow-end'}),
|
||||||
|
expect.objectContaining({source: 'states/Failed', target: 'workflow-end'}),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds focused action skeletons and continues a previous End state', () => {
|
||||||
|
const initial = {StartAt: 'Done', States: {Done: {Type: 'Pass', End: true}}}
|
||||||
|
const lambda = insertRootState(initial, 'lambda').definition as {States: Record<string, Record<string, unknown>>}
|
||||||
|
expect(lambda.States.Done).toEqual({Type: 'Pass', Next: 'LambdaInvoke'})
|
||||||
|
expect(lambda.States.LambdaInvoke).toEqual({
|
||||||
|
Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke',
|
||||||
|
Parameters: {'Payload.$': '$', FunctionName: 'Enter Lambda function name'},
|
||||||
|
OutputPath: '$.Payload',
|
||||||
|
Retry: [{ErrorEquals: ['Lambda.ServiceException', 'Lambda.AWSLambdaException', 'Lambda.SdkClientException', 'Lambda.TooManyRequestsException'], IntervalSeconds: 2, MaxAttempts: 6, BackoffRate: 2}],
|
||||||
|
End: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sns = insertRootState(initial, 'sns').definition as {States: Record<string, Record<string, unknown>>}
|
||||||
|
expect(sns.States.SnsPublish).toEqual({Type: 'Task', Resource: 'arn:aws:states:::sns:publish', Parameters: {TopicArn: ''}, End: true})
|
||||||
|
|
||||||
|
const integrations = [
|
||||||
|
['sqs-send-message', 'SqsSendMessage', 'arn:aws:states:::sqs:sendMessage'],
|
||||||
|
['dynamodb-put-item', 'DynamoDbPutItem', 'arn:aws:states:::dynamodb:putItem'],
|
||||||
|
['dynamodb-get-item', 'DynamoDbGetItem', 'arn:aws:states:::dynamodb:getItem'],
|
||||||
|
['dynamodb-update-item', 'DynamoDbUpdateItem', 'arn:aws:states:::dynamodb:updateItem'],
|
||||||
|
['dynamodb-delete-item', 'DynamoDbDeleteItem', 'arn:aws:states:::dynamodb:deleteItem'],
|
||||||
|
['eventbridge-put-events', 'EventBridgePutEvents', 'arn:aws:states:::events:putEvents'],
|
||||||
|
['ecs-run-task', 'EcsRunTask', 'arn:aws:states:::ecs:runTask'],
|
||||||
|
['ecs-run-task-sync', 'EcsRunTaskSync', 'arn:aws:states:::ecs:runTask.sync'],
|
||||||
|
['states-start-execution', 'StartExecution', 'arn:aws:states:::states:startExecution'],
|
||||||
|
] as const
|
||||||
|
integrations.forEach(([action, stateName, taskResource]) => {
|
||||||
|
const workflow = insertRootState(initial, action).definition as {States: Record<string, Record<string, unknown>>}
|
||||||
|
expect(workflow.States[stateName]).toMatchObject({Type: 'Task', Resource: taskResource, Parameters: expect.any(Object)})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows only live-audited integrations grouped by service', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
for (const name of ['Lambda Invoke', 'SNS Publish', 'SQS SendMessage', 'DynamoDB PutItem', 'DynamoDB GetItem', 'DynamoDB UpdateItem', 'DynamoDB DeleteItem', 'EventBridge PutEvents', 'ECS RunTask', 'ECS RunTask (.sync)', 'Step Functions StartExecution']) {
|
||||||
|
expect(await screen.findByRole('button', {name})).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
expect(screen.queryByRole('button', {name: /EC2 RunInstances/})).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', {name: /API Gateway Invoke/})).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a Choice with two transient branch placeholders and no dangling ASL targets', () => {
|
||||||
|
const result = insertRootState({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}}, 'choice')
|
||||||
|
const workflow = result.definition as {States: Record<string, Record<string, unknown>>}
|
||||||
|
const choice = workflow.States[result.name]
|
||||||
|
const graph = buildFlowGraph(result.definition)
|
||||||
|
|
||||||
|
expect(choice).toEqual({Type: 'Choice', Choices: [{Variable: '$.choice', IsPresent: true}]})
|
||||||
|
expect(JSON.stringify(result.definition)).not.toMatch(/placeholder|__ui|draft/i)
|
||||||
|
expect(graph.nodes.filter((node) => node.type === 'placeholder').map((node) => node.data.label)).toEqual(['Rule #1', 'Default'])
|
||||||
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({source: `states/${result.name}`, target: `states/${result.name}/placeholder-0`, label: 'Rule #1'}),
|
||||||
|
expect.objectContaining({source: `states/${result.name}`, target: `states/${result.name}/placeholder-default`, label: 'Default'}),
|
||||||
|
expect.objectContaining({source: `states/${result.name}/placeholder-0`, target: 'workflow-end'}),
|
||||||
|
expect.objectContaining({source: `states/${result.name}/placeholder-default`, target: 'workflow-end'}),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('assigns Lambda and SNS to Choice lanes, edits the rule, and saves marker-free valid ASL', async () => {
|
||||||
|
const simpleDefinition = JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}})
|
||||||
|
const simpleResource = {...resource, metadata: {...resource.metadata, definition: simpleDefinition}}
|
||||||
|
getCloudResource.mockResolvedValue(simpleResource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
updateCloudResource.mockResolvedValue(simpleResource)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(simpleResource)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Flow'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Choice'}))
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('1 error')
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', {name: 'Rule #1: Drop state here'})).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Default: Drop state here'})).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.clear(screen.getByRole('textbox', {name: 'Rule 1 variable'}))
|
||||||
|
await user.type(screen.getByRole('textbox', {name: 'Rule 1 variable'}), '$.total')
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Rule 1 operator'}), 'NumericEquals')
|
||||||
|
await user.clear(screen.getByRole('textbox', {name: 'Rule 1 value'}))
|
||||||
|
await user.type(screen.getByRole('textbox', {name: 'Rule 1 value'}), '42')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Rule #1: Drop state here'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Actions'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Lambda Invoke'}))
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Default: Drop state here'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'SNS Publish'}))
|
||||||
|
|
||||||
|
expect(screen.queryByText(/^\d+ errors?$/)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const savedText = updateCloudResource.mock.calls[0][3].definition as string
|
||||||
|
const saved = JSON.parse(savedText) as {States: Record<string, Record<string, unknown> & {Choices?: Array<Record<string, unknown>>}>}
|
||||||
|
expect(saved.States.Choice.Choices?.[0]).toMatchObject({Variable: '$.total', NumericEquals: 42, Next: 'LambdaInvoke'})
|
||||||
|
expect(saved.States.Choice.Default).toBe('SnsPublish')
|
||||||
|
expect(saved.States.LambdaInvoke).toMatchObject({Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke'})
|
||||||
|
expect(saved.States.SnsPublish).toMatchObject({Type: 'Task', Resource: 'arn:aws:states:::sns:publish'})
|
||||||
|
expect(savedText).not.toMatch(/placeholder|__ui|draft/i)
|
||||||
|
const graph = buildFlowGraph(saved)
|
||||||
|
expect(graph.nodes.some((node) => node.type === 'placeholder')).toBe(false)
|
||||||
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({source: 'states/Choice', target: 'states/LambdaInvoke'}),
|
||||||
|
expect.objectContaining({source: 'states/Choice', target: 'states/SnsPublish'}),
|
||||||
|
expect.objectContaining({source: 'states/LambdaInvoke', target: 'workflow-end'}),
|
||||||
|
expect.objectContaining({source: 'states/SnsPublish', target: 'workflow-end'}),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting an assigned Choice branch restores its placeholder and blocks saving', async () => {
|
||||||
|
const assignedDefinition = JSON.stringify({
|
||||||
|
StartAt: 'Choice',
|
||||||
|
States: {
|
||||||
|
Choice: {Type: 'Choice', Choices: [{Variable: '$.ok', BooleanEquals: true, Next: 'LambdaInvoke'}], Default: 'SnsPublish'},
|
||||||
|
LambdaInvoke: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'worker'}, End: true},
|
||||||
|
SnsPublish: {Type: 'Task', Resource: 'arn:aws:states:::sns:publish', Parameters: {TopicArn: 'topic'}, End: true},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const assignedResource = {...resource, metadata: {...resource.metadata, definition: assignedDefinition}}
|
||||||
|
getCloudResource.mockResolvedValue(assignedResource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(assignedResource)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete LambdaInvoke'}))
|
||||||
|
|
||||||
|
expect(screen.getByText('1 error')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Rule #1: Drop state here'})).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', {name: 'SnsPublish'})).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['Succeed', {Type: 'Succeed', UnknownTerminalField: {kept: true}}],
|
||||||
|
['Fail', {Type: 'Fail', Error: 'OriginalFailure', UnknownTerminalField: {kept: true}}],
|
||||||
|
])('prepends Lambda and SNS from a %s start without detached states', (_type, terminalState) => {
|
||||||
|
const initial = {StartAt: 'Terminal', States: {Terminal: terminalState}}
|
||||||
|
const lambdaResult = insertRootState(initial, 'lambda')
|
||||||
|
const snsResult = insertRootState(lambdaResult.definition, 'sns')
|
||||||
|
const workflow = snsResult.definition as {StartAt: string; States: Record<string, Record<string, unknown>>}
|
||||||
|
const graph = buildFlowGraph(snsResult.definition)
|
||||||
|
|
||||||
|
expect(workflow.StartAt).toBe('SnsPublish')
|
||||||
|
expect(workflow.States.SnsPublish).toMatchObject({Type: 'Task', Next: 'LambdaInvoke'})
|
||||||
|
expect(workflow.States.SnsPublish.End).toBeUndefined()
|
||||||
|
expect(workflow.States.LambdaInvoke).toMatchObject({Type: 'Task', Next: 'Terminal'})
|
||||||
|
expect(workflow.States.LambdaInvoke.End).toBeUndefined()
|
||||||
|
expect(workflow.States.Terminal).toEqual(terminalState)
|
||||||
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({source: 'workflow-start', target: 'states/SnsPublish'}),
|
||||||
|
expect.objectContaining({source: 'states/SnsPublish', target: 'states/LambdaInvoke'}),
|
||||||
|
expect.objectContaining({source: 'states/LambdaInvoke', target: 'states/Terminal'}),
|
||||||
|
]))
|
||||||
|
Object.values(workflow.States).forEach((state) => {
|
||||||
|
if (state.Type === 'Succeed' || state.Type === 'Fail') {
|
||||||
|
expect(state.Next).toBeUndefined()
|
||||||
|
expect(state.End).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['succeed', 'Fail'],
|
||||||
|
['fail', 'Succeed'],
|
||||||
|
] as const)('extends an explicit terminal safely when adding %s', (action, originalType) => {
|
||||||
|
const initial = {StartAt: 'Terminal', States: {Terminal: {Type: originalType, ...(originalType === 'Fail' ? {Error: 'OriginalFailure'} : {}), UnknownTerminalField: {kept: true}}}}
|
||||||
|
const result = insertRootState(initial, action)
|
||||||
|
const workflow = result.definition as {States: Record<string, Record<string, unknown>>}
|
||||||
|
|
||||||
|
expect(workflow.States.Terminal).toEqual({Type: 'Pass', Next: result.name, UnknownTerminalField: {kept: true}})
|
||||||
|
expect(workflow.States[result.name]).toMatchObject({Type: action === 'succeed' ? 'Succeed' : 'Fail'})
|
||||||
|
expect(workflow.States[result.name].Next).toBeUndefined()
|
||||||
|
expect(workflow.States[result.name].End).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renames only within the owning States scope and rewrites start, next, choice, and catch references', () => {
|
||||||
|
const source = {
|
||||||
|
StartAt: 'Work',
|
||||||
|
States: {
|
||||||
|
Work: {Type: 'Task', Next: 'Decision', Catch: [{ErrorEquals: ['States.ALL'], Next: 'Failed'}], Unknown: true},
|
||||||
|
Decision: {Type: 'Choice', Choices: [{Variable: '$.ok', BooleanEquals: true, Next: 'Work'}], Default: 'Work'},
|
||||||
|
Failed: {Type: 'Fail'},
|
||||||
|
Nested: {Type: 'Parallel', Branches: [{StartAt: 'Work', States: {Work: {Type: 'Pass', End: true}}}]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const renamed = renameStateInScope(source, ['states', 'Work'], 'Process')!.definition as {StartAt: string; States: Record<string, Record<string, unknown>>}
|
||||||
|
expect(renamed.StartAt).toBe('Process')
|
||||||
|
expect(renamed.States.Work).toBeUndefined()
|
||||||
|
expect(renamed.States.Process).toMatchObject({Next: 'Decision', Catch: [{Next: 'Failed'}], Unknown: true})
|
||||||
|
expect(renamed.States.Decision).toMatchObject({Choices: [{Next: 'Process'}], Default: 'Process'})
|
||||||
|
expect((renamed.States.Nested.Branches as Array<{StartAt: string}>)[0].StartAt).toBe('Work')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds an unattached Lambda from the palette and supports local undo and redo', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await screen.findByRole('button', {name: 'Work'})
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Lambda Invoke'}))
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Task states reference a Lambda that is not in the current inventory')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Definition'}))
|
||||||
|
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"FunctionName": "Enter Lambda function name"')
|
||||||
|
expect(screen.getByRole('button', {name: 'Undo'})).toBeEnabled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Undo'}))
|
||||||
|
expect(screen.queryByRole('button', {name: 'LambdaInvoke'})).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Redo'}))
|
||||||
|
expect(screen.getByRole('button', {name: 'LambdaInvoke'})).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the complete editable workflow definition rather than only the selected state', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Decision'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Definition'}))
|
||||||
|
const editor = screen.getByRole('textbox', {name: 'Complete ASL definition'})
|
||||||
|
expect((editor as HTMLTextAreaElement).value).toContain('"Custom": [')
|
||||||
|
expect((editor as HTMLTextAreaElement).value).toContain('"StartAt": "Work"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a state from its compact node button without selecting the node first', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await screen.findByRole('button', {name: 'Delete Work'})
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete Work'}))
|
||||||
|
expect(screen.queryByRole('button', {name: 'Delete Work'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', {name: 'Work'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses deterministic deletion for linear, terminal, catch, and converging references', () => {
|
||||||
|
const linear = deleteStateInScope({StartAt: 'A', States: {A: {Type: 'Pass', Next: 'B'}, B: {Type: 'Pass', Next: 'C'}, C: {Type: 'Pass', End: true}}}, ['states', 'B'])
|
||||||
|
expect(linear).toEqual({definition: {StartAt: 'A', States: {A: {Type: 'Pass', Next: 'C'}, C: {Type: 'Pass', End: true}}}})
|
||||||
|
|
||||||
|
const terminal = deleteStateInScope({StartAt: 'Choice', States: {
|
||||||
|
Choice: {Type: 'Choice', Choices: [{Variable: '$.kind', StringEquals: 'purchase', Next: 'Result'}, {Variable: '$.kind', StringEquals: 'refund', Next: 'Result'}], Default: 'Result'},
|
||||||
|
Purchase: {Type: 'Task', Next: 'Result', Catch: [{ErrorEquals: ['States.ALL'], Next: 'Result'}]},
|
||||||
|
Refund: {Type: 'Task', Next: 'Result'},
|
||||||
|
Result: {Type: 'Pass', End: true},
|
||||||
|
}}, ['states', 'Result'])
|
||||||
|
expect(terminal).toEqual({definition: {StartAt: 'Choice', States: {
|
||||||
|
Choice: {Type: 'Choice', Choices: [{Variable: '$.kind', StringEquals: 'purchase'}, {Variable: '$.kind', StringEquals: 'refund'}]},
|
||||||
|
Purchase: {Type: 'Task', End: true},
|
||||||
|
Refund: {Type: 'Task', End: true},
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('updates nested Parallel and Map StartAt and rejects sole nested and self-loop deletion', () => {
|
||||||
|
const source = {StartAt: 'Parallel', States: {
|
||||||
|
Parallel: {Type: 'Parallel', Branches: [{StartAt: 'First', States: {First: {Type: 'Pass', Next: 'Second'}, Second: {Type: 'Pass', End: true}}}], Next: 'Map'},
|
||||||
|
Map: {Type: 'Map', ItemProcessor: {StartAt: 'ItemFirst', States: {ItemFirst: {Type: 'Pass', Next: 'ItemSecond'}, ItemSecond: {Type: 'Pass', End: true}}}, End: true},
|
||||||
|
}}
|
||||||
|
const parallel = deleteStateInScope(source, ['states', 'Parallel', 'branch-0', 'First'])
|
||||||
|
expect('definition' in parallel && ((parallel.definition.States as Record<string, Record<string, unknown>>).Parallel.Branches as Array<{StartAt: string}>)[0].StartAt).toBe('Second')
|
||||||
|
const map = deleteStateInScope('definition' in parallel ? parallel.definition : source, ['states', 'Map', 'items', 'ItemFirst'])
|
||||||
|
expect('definition' in map && (((map.definition.States as Record<string, Record<string, unknown>>).Map.ItemProcessor as {StartAt: string}).StartAt)).toBe('ItemSecond')
|
||||||
|
expect(deleteStateInScope({StartAt: 'Parallel', States: {Parallel: {Type: 'Parallel', Branches: [{StartAt: 'Only', States: {Only: {Type: 'Pass', End: true}}}], End: true}}}, ['states', 'Parallel', 'branch-0', 'Only'])).toEqual({error: 'A Parallel branch or Map item scope must contain at least one state.'})
|
||||||
|
expect(deleteStateInScope({StartAt: 'Loop', States: {Loop: {Type: 'Pass', Next: 'Loop'}, Other: {Type: 'Pass', End: true}}}, ['states', 'Loop'])).toEqual({error: 'Cannot delete Loop because it transitions to itself.'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows a sole root state to become a transient empty draft but prevents saving it', async () => {
|
||||||
|
const sole = JSON.stringify({StartAt: 'Only', States: {Only: {Type: 'Pass', End: true}}})
|
||||||
|
const soleResource = {...resource, metadata: {...resource.metadata, definition: sole}}
|
||||||
|
getCloudResource.mockResolvedValue(soleResource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(soleResource)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete Only'}))
|
||||||
|
expect(screen.getByText(/Workflow must contain at least one state/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
|
||||||
|
expect(screen.getAllByText('Add your first state')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks toolbar deletion of boundaries and Choice placeholders with a visible message', async () => {
|
||||||
|
const choiceResource = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Choice', States: {Choice: {Type: 'Choice', Choices: [{Variable: '$.ok', IsPresent: true}]}}})}}
|
||||||
|
getCloudResource.mockResolvedValue(choiceResource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(choiceResource)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Start'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete selected state'}))
|
||||||
|
expect(screen.getByText(/Start, End, empty guidance, and Choice branch placeholders cannot be deleted/)).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Rule #1: Drop state here'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete selected state'}))
|
||||||
|
expect(screen.getByRole('button', {name: 'Choice'})).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Start, End, empty guidance, and Choice branch placeholders cannot be deleted/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('imports and renders a complete two-rule Choice workflow converging on one Result Handler', async () => {
|
||||||
|
const initialResource = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}})}}
|
||||||
|
const retry = [{
|
||||||
|
ErrorEquals: ['Lambda.ServiceException', 'Lambda.AWSLambdaException', 'Lambda.SdkClientException', 'Lambda.TooManyRequestsException'],
|
||||||
|
IntervalSeconds: 2,
|
||||||
|
MaxAttempts: 6,
|
||||||
|
BackoffRate: 2,
|
||||||
|
}]
|
||||||
|
const supplied = {
|
||||||
|
Comment: 'A description of my state machine',
|
||||||
|
StartAt: 'Choice',
|
||||||
|
States: {
|
||||||
|
Choice: {Type: 'Choice', Choices: [{Variable: '$.type', StringEquals: 'PURCHASE', Next: 'Purchase Handler'}, {Variable: '$.type', StringEquals: 'REFUND', Next: 'Refund Handler'}]},
|
||||||
|
'Purchase Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', OutputPath: '$.Payload', Parameters: {'Payload.$': '$', FunctionName: 'arn:aws:lambda:us-east-1:755314965794:function:PurchaseHandler:$LATEST'}, Retry: retry, Next: 'Result Handler'},
|
||||||
|
'Result Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', OutputPath: '$.Payload', Parameters: {'Payload.$': '$', FunctionName: 'arn:aws:lambda:us-east-1:755314965794:function:ResultHandler:$LATEST'}, Retry: retry, End: true},
|
||||||
|
'Refund Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', OutputPath: '$.Payload', Parameters: {'Payload.$': '$', FunctionName: 'arn:aws:lambda:us-east-1:755314965794:function:RefundHandler:$LATEST'}, Retry: retry, Next: 'Result Handler'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
getCloudResource.mockResolvedValue(initialResource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
updateCloudResource.mockResolvedValue(initialResource)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(initialResource)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Definition'}))
|
||||||
|
fireEvent.change(screen.getByRole('textbox', {name: 'Complete ASL definition'}), {target: {value: JSON.stringify(supplied, null, 2)}})
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Apply to graph'}))
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', {name: 'Purchase Handler'})).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Refund Handler'})).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByRole('button', {name: 'Result Handler'})).toHaveLength(1)
|
||||||
|
const graph = buildFlowGraph(supplied)
|
||||||
|
expect(graph.edges.filter((edge) => edge.target === 'states/Result Handler')).toHaveLength(2)
|
||||||
|
expect(graph.nodes.some((node) => node.type === 'placeholder')).toBe(false)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'Purchase Handler')?.position.x).toBeLessThan(graph.nodes.find((node) => node.data.label === 'Choice')?.position.x ?? 0)
|
||||||
|
expect(graph.nodes.find((node) => node.data.label === 'Refund Handler')?.position.x).toBeGreaterThan(graph.nodes.find((node) => node.data.label === 'Choice')?.position.x ?? 0)
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
expect(JSON.parse(updateCloudResource.mock.calls[0][3].definition)).toEqual(supplied)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits ordinary transitions and Choice targets to existing same-scope states', async () => {
|
||||||
|
const transitions = {StartAt: 'Choice', States: {
|
||||||
|
Choice: {Type: 'Choice', Choices: [{Variable: '$.action', StringEquals: 'purchase', Next: 'Purchase Handler'}], Default: 'Refund Handler'},
|
||||||
|
'Purchase Handler': {Type: 'Pass', End: true},
|
||||||
|
'Refund Handler': {Type: 'Pass', End: true},
|
||||||
|
'Result Handler': {Type: 'Succeed'},
|
||||||
|
}}
|
||||||
|
const transitionsResource = {...resource, metadata: {...resource.metadata, definition: JSON.stringify(transitions)}}
|
||||||
|
getCloudResource.mockResolvedValue(transitionsResource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
updateCloudResource.mockResolvedValue(transitionsResource)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(transitionsResource)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Choice'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Rule 1 target'}), 'Result Handler')
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Default target'}), 'Result Handler')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Purchase Handler'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'State transition'}), 'Result Handler')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Refund Handler'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'State transition'}), 'Result Handler')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as typeof transitions
|
||||||
|
expect(saved.States.Choice).toMatchObject({Choices: [{Next: 'Result Handler'}], Default: 'Result Handler'})
|
||||||
|
expect(saved.States['Purchase Handler']).toEqual({Type: 'Pass', Next: 'Result Handler'})
|
||||||
|
expect(saved.States['Refund Handler']).toEqual({Type: 'Pass', Next: 'Result Handler'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('splices an unattached Lambda into only the selected Choice edge', () => {
|
||||||
|
const supplied = {StartAt: 'Choice', States: {
|
||||||
|
Choice: {Type: 'Choice', Choices: [
|
||||||
|
{Variable: '$.type', StringEquals: 'PURCHASE', Next: 'Purchase Handler'},
|
||||||
|
{Variable: '$.type', StringEquals: 'REFUND', Next: 'Refund Handler'},
|
||||||
|
]},
|
||||||
|
'Purchase Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {'Payload.$': '$', FunctionName: 'purchase'}, Retry: [{ErrorEquals: ['States.ALL'], MaxAttempts: 3}], Next: 'Result Handler'},
|
||||||
|
'Refund Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {'Payload.$': '$', FunctionName: 'refund'}, Next: 'Result Handler'},
|
||||||
|
'Result Handler': {Type: 'Succeed'},
|
||||||
|
}}
|
||||||
|
const result = spliceLambdaIntoEdge(supplied, {sourcePath: ['states', 'Choice'], kind: 'choice', choiceIndex: 0})
|
||||||
|
const saved = result?.definition as typeof supplied & {States: Record<string, Record<string, unknown>>}
|
||||||
|
expect(saved.States.Choice.Choices[0].Next).toBe('LambdaInvoke')
|
||||||
|
expect(saved.States.Choice.Choices[1]).toEqual(supplied.States.Choice.Choices[1])
|
||||||
|
expect(saved.States.LambdaInvoke).toMatchObject({Type: 'Task', Next: 'Purchase Handler'})
|
||||||
|
expect(saved.States['Purchase Handler']).toEqual(supplied.States['Purchase Handler'])
|
||||||
|
expect(saved.States['Refund Handler']).toEqual(supplied.States['Refund Handler'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('splices any confirmed service integration into the selected edge', () => {
|
||||||
|
const source = {StartAt: 'First', States: {First: {Type: 'Pass', Next: 'Last'}, Last: {Type: 'Succeed'}}}
|
||||||
|
const result = spliceTaskIntoEdge(source, {sourcePath: ['states', 'First'], kind: 'next'}, 'sqs-send-message')
|
||||||
|
const states = result?.definition.States as Record<string, Record<string, unknown>>
|
||||||
|
|
||||||
|
expect(states.First.Next).toBe('SqsSendMessage')
|
||||||
|
expect(states.SqsSendMessage).toMatchObject({Resource: 'arn:aws:states:::sqs:sendMessage', Next: 'Last'})
|
||||||
|
expect(states.Last).toEqual(source.States.Last)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loads the definition once for the selected machine despite a list refresh', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
|
||||||
|
const {queryClient, rerender} = renderStudio()
|
||||||
|
|
||||||
|
await screen.findByRole('button', {name: 'Decision'})
|
||||||
|
rerender(<QueryClientProvider client={queryClient}><WorkflowStudio resource={{...resource, name: 'renamed in list'}}/></QueryClientProvider>)
|
||||||
|
|
||||||
|
expect(getCloudResource).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows a describe failure', async () => {
|
||||||
|
getCloudResource.mockRejectedValue(new Error('DescribeStateMachine failed'))
|
||||||
|
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Unable to load state machine definition: DescribeStateMachine failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('marks a direct Lambda ARN absent from inventory and rewrites only its Resource', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([{
|
||||||
|
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
|
||||||
|
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
|
||||||
|
}, {
|
||||||
|
id: 'local', name: 'local', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
|
||||||
|
metadata: {arn: 'arn:aws:lambda:local'},
|
||||||
|
}])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('2 Task states reference a Lambda that is not in the current inventory')
|
||||||
|
await user.selectOptions(await screen.findByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
|
||||||
|
|
||||||
|
expect(screen.queryByRole('combobox', {name: 'Lambda function'})).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Definition'}))
|
||||||
|
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"Resource": "arn:aws:lambda:us-east-1:000000000000:function:orders-worker"')
|
||||||
|
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"UnknownTaskField":')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recognizes Lambda service integrations and rewrites only Parameters.FunctionName', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([{
|
||||||
|
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
|
||||||
|
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
|
||||||
|
}, {
|
||||||
|
id: 'local', name: 'local', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
|
||||||
|
metadata: {arn: 'arn:aws:lambda:local'},
|
||||||
|
}])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'IntegrationMissing'}))
|
||||||
|
await user.selectOptions(await screen.findByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Definition'}))
|
||||||
|
const editor = screen.getByRole('textbox', {name: 'Complete ASL definition'})
|
||||||
|
expect((editor as HTMLTextAreaElement).value).toContain('"Resource": "arn:aws:states:::lambda:invoke"')
|
||||||
|
expect((editor as HTMLTextAreaElement).value).toContain('"FunctionName": "arn:aws:lambda:us-east-1:000000000000:function:orders-worker"')
|
||||||
|
expect((editor as HTMLTextAreaElement).value).toContain('"Payload":')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits Lambda retry and catch rules while preserving rule fields and refreshing catch targets', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Work'}))
|
||||||
|
expect(screen.getByRole('heading', {name: 'Task policy'})).toBeInTheDocument()
|
||||||
|
await user.clear(screen.getByRole('spinbutton', {name: 'Retry 1 IntervalSeconds'}))
|
||||||
|
await user.type(screen.getByRole('spinbutton', {name: 'Retry 1 IntervalSeconds'}), '5')
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Catch 1 Next'}), 'Decision')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Add retry'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Add catch'}))
|
||||||
|
await user.click(screen.getAllByRole('button', {name: 'Remove retry'})[1])
|
||||||
|
await user.click(screen.getAllByRole('button', {name: 'Remove catch'})[1])
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, Record<string, unknown> & {Retry: Array<Record<string, unknown>>; Catch: Array<Record<string, unknown>>}>}
|
||||||
|
expect(saved.States.Work.Retry).toEqual([expect.objectContaining({ErrorEquals: ['States.ALL'], IntervalSeconds: 5, UnknownRetryField: {kept: true}})])
|
||||||
|
expect(saved.States.Work.Catch).toEqual([expect.objectContaining({ErrorEquals: ['States.ALL'], Next: 'Decision', UnknownCatchField: {kept: true}})])
|
||||||
|
expect(saved.States.Work.UnknownTaskField).toEqual({kept: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaches an inventory SNS topic to an SNS publish task without replacing Parameters', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'notifications' ? [{
|
||||||
|
id: 'arn:aws:sns:us-east-1:000000000000:orders', name: 'orders', cloud: 'aws', service: 'notifications', type: 'topic', region: null, createdAt: null, metadata: {},
|
||||||
|
}] : []))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'PublishNotice'}))
|
||||||
|
expect(screen.getByRole('heading', {name: 'SNS Publish Task'})).toBeInTheDocument()
|
||||||
|
expect(listCloudResources).toHaveBeenCalledWith('aws', 'notifications', undefined, expect.any(AbortSignal))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'SNS topic'}), 'arn:aws:sns:us-east-1:000000000000:orders')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
|
||||||
|
expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'keep'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits SQS parameters from queue inventory and keeps shared task policy controls', async () => {
|
||||||
|
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Send', States: {Send: {Type: 'Task', Resource: 'arn:aws:states:::sqs:sendMessage', Parameters: {QueueUrl: 'old', MessageBody: 'old', Unknown: 'keep'}, End: true}}})}}
|
||||||
|
getCloudResource.mockResolvedValue(selected)
|
||||||
|
updateCloudResource.mockResolvedValue(selected)
|
||||||
|
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'messaging' ? [{id: 'orders', name: 'orders', cloud: 'aws', service: 'messaging', type: 'queue', region: null, createdAt: null, metadata: {queueUrl: 'http://localhost:4566/000000000000/orders'}}] : []))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(selected)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', {name: 'SQS SendMessage'})).toBeInTheDocument()
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'SQS queue'}), 'http://localhost:4566/000000000000/orders')
|
||||||
|
await user.clear(screen.getByRole('textbox', {name: 'SQS message body'}))
|
||||||
|
await user.type(screen.getByRole('textbox', {name: 'SQS message body'}), 'hello from workflow')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Add retry'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>; Retry: unknown[]}>}
|
||||||
|
expect(saved.States.Send.Parameters).toEqual({QueueUrl: 'http://localhost:4566/000000000000/orders', MessageBody: 'hello from workflow', Unknown: 'keep'})
|
||||||
|
expect(saved.States.Send.Retry).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits DynamoDB operation fields with a real table picker', async () => {
|
||||||
|
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Put', States: {Put: {Type: 'Task', Resource: 'arn:aws:states:::dynamodb:putItem', Parameters: {TableName: 'old', Item: {id: {S: 'old'}}, Unknown: true}, End: true}}})}}
|
||||||
|
getCloudResource.mockResolvedValue(selected)
|
||||||
|
updateCloudResource.mockResolvedValue(selected)
|
||||||
|
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'nosql' ? [{id: 'orders', name: 'orders', cloud: 'aws', service: 'nosql', type: 'dynamodb-table', region: null, createdAt: null, metadata: {}}] : []))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(selected)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', {name: 'DynamoDB PutItem'})).toBeInTheDocument()
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'DynamoDB table'}), 'orders')
|
||||||
|
fireEvent.change(screen.getByRole('textbox', {name: 'Item'}), {target: {value: '{"id":{"S":"new"},"total":{"N":"42"}}'}})
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
|
||||||
|
expect(saved.States.Put.Parameters).toEqual({TableName: 'orders', Item: {id: {S: 'new'}, total: {N: '42'}}, Unknown: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits EventBridge entries without collapsing imported entries', async () => {
|
||||||
|
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Events', States: {Events: {Type: 'Task', Resource: 'arn:aws:states:::events:putEvents', Parameters: {Entries: [{EventBusName: 'old', Source: 'old.source', DetailType: 'old', Detail: {before: true}, Unknown: 'keep'}, {EventBusName: 'second', Source: 'second.source', DetailType: 'second', Detail: '{}'}]}, End: true}}})}}
|
||||||
|
getCloudResource.mockResolvedValue(selected)
|
||||||
|
updateCloudResource.mockResolvedValue(selected)
|
||||||
|
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'events' ? [{id: 'event-bus:orders', name: 'orders', cloud: 'aws', service: 'events', type: 'event-bus', region: null, createdAt: null, metadata: {}}] : []))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(selected)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', {name: 'EventBridge PutEvents'})).toBeInTheDocument()
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'EventBridge entry 1 event bus'}), 'orders')
|
||||||
|
await user.clear(screen.getByRole('textbox', {name: 'EventBridge entry 1 source'}))
|
||||||
|
await user.type(screen.getByRole('textbox', {name: 'EventBridge entry 1 source'}), 'orders.created')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: {Entries: Array<Record<string, unknown>>}}>}
|
||||||
|
expect(saved.States.Events.Parameters.Entries[0]).toMatchObject({EventBusName: 'orders', Source: 'orders.created', Unknown: 'keep'})
|
||||||
|
expect(saved.States.Events.Parameters.Entries[1]).toEqual({EventBusName: 'second', Source: 'second.source', DetailType: 'second', Detail: '{}'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits ECS and nested workflow parameters through action-specific forms', async () => {
|
||||||
|
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Ecs', States: {Ecs: {Type: 'Task', Resource: 'arn:aws:states:::ecs:runTask.sync', Parameters: {Cluster: 'old', TaskDefinition: 'old'}, Next: 'Nested'}, Nested: {Type: 'Task', Resource: 'arn:aws:states:::states:startExecution', Parameters: {StateMachineArn: 'old', Input: {keep: true}}, End: true}}})}}
|
||||||
|
const childArn = 'arn:aws:states:us-east-1:000000000000:stateMachine:child'
|
||||||
|
getCloudResource.mockResolvedValue(selected)
|
||||||
|
updateCloudResource.mockResolvedValue(selected)
|
||||||
|
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'workflows' ? [{id: childArn, name: 'child', cloud: 'aws', service: 'workflows', type: 'state-machine', region: null, createdAt: null, metadata: {machineType: 'STANDARD'}}] : []))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio(selected)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', {name: 'ECS RunTask (.sync)'})).toBeInTheDocument()
|
||||||
|
await user.clear(screen.getByRole('textbox', {name: 'ECS cluster'}))
|
||||||
|
await user.type(screen.getByRole('textbox', {name: 'ECS cluster'}), 'audit-cluster')
|
||||||
|
await user.clear(screen.getByRole('textbox', {name: 'ECS task definition'}))
|
||||||
|
await user.type(screen.getByRole('textbox', {name: 'ECS task definition'}), 'audit-task:1')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Nested'}))
|
||||||
|
expect(screen.getByRole('heading', {name: 'Step Functions StartExecution'})).toBeInTheDocument()
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Nested state machine'}), childArn)
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
|
||||||
|
expect(saved.States.Ecs.Parameters).toMatchObject({Cluster: 'audit-cluster', TaskDefinition: 'audit-task:1'})
|
||||||
|
expect(saved.States.Nested.Parameters).toEqual({StateMachineArn: childArn, Input: {keep: true}})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('saves the complete local ASL draft through the generic resource update API', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([{
|
||||||
|
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
|
||||||
|
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
|
||||||
|
}])
|
||||||
|
let resolveUpdate: (value: CloudResource) => void = () => undefined
|
||||||
|
updateCloudResource.mockImplementation(() => new Promise<CloudResource>((resolve) => {
|
||||||
|
resolveUpdate = resolve
|
||||||
|
}))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const {queryClient} = renderStudio()
|
||||||
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', {name: 'Save definition'})
|
||||||
|
expect(saveButton).toBeDisabled()
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
|
||||||
|
await user.click(saveButton)
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Saving definition.')
|
||||||
|
const expectedDraft = JSON.parse(definition) as {States: Record<string, Record<string, unknown>>}
|
||||||
|
expectedDraft.States.DirectMissing.Resource = 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'
|
||||||
|
const expectedDefinition = JSON.stringify(expectedDraft)
|
||||||
|
expect(updateCloudResource).toHaveBeenCalledWith('aws', 'workflows', resource.id, {
|
||||||
|
definition: expectedDefinition,
|
||||||
|
})
|
||||||
|
const savedDefinition = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as Record<string, unknown>
|
||||||
|
const savedStates = savedDefinition.States as Record<string, Record<string, unknown>>
|
||||||
|
expect(savedStates.DirectMissing).toMatchObject({
|
||||||
|
Resource: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker',
|
||||||
|
UnknownTaskField: {kept: true},
|
||||||
|
})
|
||||||
|
expect((savedStates.IntegrationMissing.Parameters as Record<string, unknown>).Payload).toEqual({keep: true})
|
||||||
|
|
||||||
|
resolveUpdate({...resource, metadata: {...resource.metadata, definition: expectedDefinition}})
|
||||||
|
expect(await screen.findByText('Definition saved.')).toBeInTheDocument()
|
||||||
|
expect(saveButton).toBeDisabled()
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({queryKey: ['cloud-resource', 'aws', 'workflows', resource.id]})
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({queryKey: ['cloud-console-resources', 'aws', 'workflows']})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retains the local draft and shows an accessible error when save fails', async () => {
|
||||||
|
getCloudResource.mockResolvedValue(resource)
|
||||||
|
listCloudResources.mockResolvedValue([{
|
||||||
|
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
|
||||||
|
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
|
||||||
|
}])
|
||||||
|
updateCloudResource.mockRejectedValue(new Error('UpdateStateMachine failed'))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Unable to save definition: UpdateStateMachine failed')).toHaveAttribute('role', 'alert')
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Definition'}))
|
||||||
|
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"UnknownTaskField":')
|
||||||
|
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"Resource": "arn:aws:lambda:us-east-1:000000000000:function:orders-worker"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reloads a clean definition immediately and asks before discarding a draft', async () => {
|
||||||
|
const fresh = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Fresh', States: {Fresh: {Type: 'Pass', End: true}}})}}
|
||||||
|
getCloudResource.mockResolvedValueOnce(resource).mockResolvedValueOnce(resource).mockResolvedValueOnce(fresh)
|
||||||
|
listCloudResources.mockResolvedValue([{id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'}}])
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Reload'}))
|
||||||
|
expect(await screen.findByRole('button', {name: 'DirectMissing'})).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'DirectMissing'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Reload'}))
|
||||||
|
expect(screen.getByRole('alertdialog')).toHaveTextContent('Discard local changes?')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Reload definition'}))
|
||||||
|
expect(await screen.findByRole('button', {name: 'Fresh'})).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checks the latest revision before saving and only overwrites after confirmation', async () => {
|
||||||
|
const loaded = {...resource, metadata: {...resource.metadata, revisionId: 'revision-1'}}
|
||||||
|
const changed = {...resource, metadata: {...resource.metadata, revisionId: 'revision-2'}}
|
||||||
|
getCloudResource.mockResolvedValueOnce(loaded).mockResolvedValueOnce(changed)
|
||||||
|
listCloudResources.mockResolvedValue([{id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'}}])
|
||||||
|
updateCloudResource.mockResolvedValue(loaded)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderStudio()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save definition'}))
|
||||||
|
expect(await screen.findByText('Definition changed elsewhere')).toBeInTheDocument()
|
||||||
|
expect(updateCloudResource).not.toHaveBeenCalled()
|
||||||
|
expect(getCloudResource).toHaveBeenCalledWith('aws', 'workflows', resource.id)
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Overwrite'}))
|
||||||
|
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
|
||||||
|
expect(updateCloudResource.mock.calls[0][3]).toEqual(expect.objectContaining({definition: expect.any(String)}))
|
||||||
|
expect(updateCloudResource.mock.calls[0][3]).not.toHaveProperty('revisionId')
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,46 @@
|
|||||||
--bulk-bar-border: #5c6bc0;
|
--bulk-bar-border: #5c6bc0;
|
||||||
--bulk-bar-text: #e8eaf6;
|
--bulk-bar-text: #e8eaf6;
|
||||||
|
|
||||||
|
/* Workflow Studio: keep canvas surfaces readable independently of state accents. */
|
||||||
|
--workflow-save-border: #4e628f;
|
||||||
|
--workflow-save-bg: #18233c;
|
||||||
|
--workflow-save-text: #dce7ff;
|
||||||
|
--workflow-save-hover-border: #7498e6;
|
||||||
|
--workflow-save-hover-bg: #21345a;
|
||||||
|
--workflow-canvas-bg: #f2f4f7;
|
||||||
|
--workflow-canvas-dot: rgba(58, 72, 98, .22);
|
||||||
|
--workflow-toolbar-bg: #ffffff;
|
||||||
|
--workflow-node-bg: #ffffff;
|
||||||
|
--workflow-node-text: #162033;
|
||||||
|
--workflow-node-muted: #526077;
|
||||||
|
--workflow-node-border: #73819a;
|
||||||
|
--workflow-node-accent: #6389e8;
|
||||||
|
--workflow-choice-border: #9a7137;
|
||||||
|
--workflow-container-border: #5773a9;
|
||||||
|
--workflow-container-bg: rgba(38, 56, 95, .36);
|
||||||
|
--workflow-warning-border: #bf7b37;
|
||||||
|
--workflow-warning-text: #f3b465;
|
||||||
|
--workflow-task: #5d8dff;
|
||||||
|
--workflow-choice: #d99a3d;
|
||||||
|
--workflow-pass: #76b7a1;
|
||||||
|
--workflow-pass-border: #4f8073;
|
||||||
|
--workflow-wait: #a480df;
|
||||||
|
--workflow-wait-border: #73589b;
|
||||||
|
--workflow-parallel: #d279bd;
|
||||||
|
--workflow-parallel-border: #9d568d;
|
||||||
|
--workflow-map: #45b1bd;
|
||||||
|
--workflow-map-border: #39848d;
|
||||||
|
--workflow-start-border: #4d9d82;
|
||||||
|
--workflow-start-text: #8de0b7;
|
||||||
|
--workflow-end-border: #ab5a67;
|
||||||
|
--workflow-end-text: #f19aa6;
|
||||||
|
--workflow-validation-border: #8f652f;
|
||||||
|
--workflow-validation-bg: rgba(143, 101, 47, .16);
|
||||||
|
--workflow-validation-text: #f2c47e;
|
||||||
|
--workflow-confirmation-border: #657da9;
|
||||||
|
--workflow-confirmation-bg: rgba(42, 61, 96, .34);
|
||||||
|
--workflow-edge: #59677d;
|
||||||
|
|
||||||
/* ── Sidebar geometry ── */
|
/* ── Sidebar geometry ── */
|
||||||
--sidebar-w: 220px;
|
--sidebar-w: 220px;
|
||||||
--sidebar-rail-w: 52px;
|
--sidebar-rail-w: 52px;
|
||||||
@@ -116,6 +156,45 @@
|
|||||||
--bulk-bar-border: #3949ab;
|
--bulk-bar-border: #3949ab;
|
||||||
--bulk-bar-text: #1a237e;
|
--bulk-bar-text: #1a237e;
|
||||||
|
|
||||||
|
--workflow-save-border: #5565a7;
|
||||||
|
--workflow-save-bg: #e7ecff;
|
||||||
|
--workflow-save-text: #27366e;
|
||||||
|
--workflow-save-hover-border: #3949ab;
|
||||||
|
--workflow-save-hover-bg: #d9e2ff;
|
||||||
|
--workflow-canvas-bg: #f6f7f9;
|
||||||
|
--workflow-canvas-dot: rgba(69, 82, 108, .2);
|
||||||
|
--workflow-toolbar-bg: #ffffff;
|
||||||
|
--workflow-node-bg: #ffffff;
|
||||||
|
--workflow-node-text: #111827;
|
||||||
|
--workflow-node-muted: #4b5565;
|
||||||
|
--workflow-node-border: #7180b0;
|
||||||
|
--workflow-node-accent: #3949ab;
|
||||||
|
--workflow-choice-border: #a76519;
|
||||||
|
--workflow-container-border: #6679ad;
|
||||||
|
--workflow-container-bg: rgba(73, 95, 159, .08);
|
||||||
|
--workflow-warning-border: #a85f13;
|
||||||
|
--workflow-warning-text: #7a3f00;
|
||||||
|
--workflow-task: #1d4ed8;
|
||||||
|
--workflow-choice: #b45309;
|
||||||
|
--workflow-pass: #177a62;
|
||||||
|
--workflow-pass-border: #27806b;
|
||||||
|
--workflow-wait: #7040a6;
|
||||||
|
--workflow-wait-border: #7950a5;
|
||||||
|
--workflow-parallel: #ad3d87;
|
||||||
|
--workflow-parallel-border: #9f4d84;
|
||||||
|
--workflow-map: #147a88;
|
||||||
|
--workflow-map-border: #2d7b86;
|
||||||
|
--workflow-start-border: #24795f;
|
||||||
|
--workflow-start-text: #145a45;
|
||||||
|
--workflow-end-border: #a63b48;
|
||||||
|
--workflow-end-text: #862a35;
|
||||||
|
--workflow-validation-border: #a85f13;
|
||||||
|
--workflow-validation-bg: #fff4df;
|
||||||
|
--workflow-validation-text: #6f3900;
|
||||||
|
--workflow-confirmation-border: #6e7eb3;
|
||||||
|
--workflow-confirmation-bg: #edf1ff;
|
||||||
|
--workflow-edge: #5c6b91;
|
||||||
|
|
||||||
/* 45% black at 32px blur is far too heavy under a white panel. */
|
/* 45% black at 32px blur is far too heavy under a white panel. */
|
||||||
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
|
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
|
||||||
}
|
}
|
||||||
@@ -820,6 +899,165 @@ pre,
|
|||||||
flex: 1 1 160px;
|
flex: 1 1 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-studio-page { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; min-width: 0; }
|
||||||
|
.workflow-studio-workspace { min-height: 0; max-width: 100%; padding: 0; overflow: hidden; }
|
||||||
|
.workflow-studio { display: flex; height: 100%; min-height: 0; flex-direction: column; border: 0; border-radius: 0; }
|
||||||
|
.workflow-studio > .dynamic-stage-header { min-height: 58px; margin: 0; padding: 9px 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.workflow-studio > .dynamic-stage-header h3 { margin: 1px 0 0; font-size: 15px; }
|
||||||
|
.workflow-studio > .compact-text { margin: 0; padding: 7px 18px; border-bottom: 1px solid var(--border-faint); }
|
||||||
|
.workflow-studio-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.workflow-save-definition { min-height: 30px; border-color: var(--workflow-save-border); background: var(--workflow-save-bg); color: var(--workflow-save-text); font-size: 11px; }
|
||||||
|
.workflow-save-definition:not(:disabled):hover { border-color: var(--workflow-save-hover-border); background: var(--workflow-save-hover-bg); }
|
||||||
|
.workflow-studio-split { display: grid; min-height: 0; flex: 1; grid-template-columns: 280px minmax(440px, 1fr) 350px; border-top: 1px solid var(--border); }
|
||||||
|
.workflow-action-palette, .workflow-studio-json { min-width: 0; overflow-x: hidden; overflow-y: auto; background: var(--surface); scrollbar-gutter: stable; }
|
||||||
|
.workflow-action-palette { padding: 18px 16px; border-right: 1px solid var(--border); }
|
||||||
|
.workflow-studio-json { padding: 18px; border-left: 1px solid var(--border); }
|
||||||
|
.workflow-studio-json > * { min-width: 0; max-width: 100%; }
|
||||||
|
.workflow-pane-title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; color: var(--text-bright); font-size: 15px; font-weight: 700; }
|
||||||
|
.workflow-pane-title small { overflow: hidden; color: var(--text-3); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
|
||||||
|
.workflow-palette-search { position: relative; display: block; margin: 16px 0 12px; }
|
||||||
|
.workflow-palette-search svg { position: absolute; top: 9px; left: 10px; color: var(--text-3); }
|
||||||
|
.workflow-palette-search input { width: 100%; height: 34px; padding: 0 10px 0 32px; border: 1px solid var(--input-border); border-radius: 3px; background: var(--input-bg); color: var(--text); font-size: 12px; }
|
||||||
|
.workflow-palette-tabs { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--border); }
|
||||||
|
.workflow-palette-tabs button { justify-content: center; margin: 0; padding: 8px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--text-3); font-size: 12px; font-weight: 700; }
|
||||||
|
.workflow-palette-tabs button.active { border-bottom-color: var(--accent); color: var(--text-bright); }
|
||||||
|
.workflow-action-palette > p { margin: 17px 0 8px; color: var(--text-3); font-size: 10px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.workflow-palette-cards { display: grid; gap: 8px; }
|
||||||
|
.workflow-palette-cards > button { display: flex; width: 100%; align-items: center; gap: 10px; margin: 0; padding: 9px; border: 1px solid var(--border); border-radius: 4px; background: var(--elevated); color: var(--text); text-align: left; box-shadow: 0 2px 5px rgba(0, 0, 0, .12); }
|
||||||
|
.workflow-palette-cards > button:hover { border-color: var(--accent); background: var(--raised); }
|
||||||
|
.workflow-palette-cards > button > span:last-child { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
.workflow-palette-cards strong { font-size: 12px; }
|
||||||
|
.workflow-palette-cards small { color: var(--text-3); font-size: 9px; }
|
||||||
|
.workflow-palette-icon { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border-radius: 3px; background: var(--accent-tint); color: var(--accent); }
|
||||||
|
.workflow-palette-service-group { display: grid; gap: 6px; margin: 0 0 12px; }
|
||||||
|
.workflow-palette-service-group h4 { margin: 0; color: var(--text-2); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.workflow-palette-lambda { background: rgba(255, 153, 0, .15); color: #d97706; }
|
||||||
|
.workflow-palette-sns { background: rgba(196, 56, 123, .14); color: #c4387b; }
|
||||||
|
.workflow-palette-sqs-send-message { background: rgba(209, 79, 100, .14); color: #c2415a; }
|
||||||
|
.workflow-palette-dynamodb-put-item, .workflow-palette-dynamodb-get-item, .workflow-palette-dynamodb-update-item, .workflow-palette-dynamodb-delete-item { background: rgba(47, 110, 181, .14); color: #2f6eb5; }
|
||||||
|
.workflow-palette-eventbridge-put-events { background: rgba(210, 68, 139, .14); color: #c3347a; }
|
||||||
|
.workflow-palette-ecs-run-task, .workflow-palette-ecs-run-task-sync { background: rgba(232, 119, 34, .14); color: #d46613; }
|
||||||
|
.workflow-palette-states-start-execution { background: rgba(119, 79, 184, .14); color: #7650b5; }
|
||||||
|
.workflow-palette-choice { background: rgba(217, 154, 61, .14); color: var(--workflow-choice); }
|
||||||
|
.workflow-palette-wait { background: rgba(164, 128, 223, .14); color: var(--workflow-wait); }
|
||||||
|
.workflow-branch-hint { margin-top: 14px; padding: 9px; border: 1px solid var(--workflow-choice-border); border-radius: 3px; color: var(--workflow-warning-text); font-size: 11px; line-height: 1.45; }
|
||||||
|
.workflow-studio-canvas { position: relative; min-height: 0; background: radial-gradient(circle at 1px 1px, var(--workflow-canvas-dot) 1px, transparent 0) 0 0 / 20px 20px, var(--workflow-canvas-bg); }
|
||||||
|
.workflow-canvas-toolbar { position: absolute; z-index: 5; top: 12px; left: 50%; display: flex; gap: 2px; padding: 3px; transform: translateX(-50%); border: 1px solid #c4cad4; border-radius: 4px; background: var(--workflow-toolbar-bg); box-shadow: 0 2px 8px rgba(29, 38, 55, .16); }
|
||||||
|
.workflow-canvas-toolbar span { width: 1px; margin: 3px; background: #d7dbe2; }
|
||||||
|
.workflow-canvas-toolbar button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 2px; background: transparent; color: #38455a; }
|
||||||
|
.workflow-canvas-toolbar button:hover:not(:disabled) { background: #e8ecf4; color: #111827; }
|
||||||
|
.workflow-canvas-toolbar button:disabled { color: #a1a9b5; cursor: not-allowed; }
|
||||||
|
.workflow-inspector-tabs { display: flex; gap: 20px; margin: 17px -18px 18px; padding: 0 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.workflow-inspector-tabs button { padding: 0 1px 10px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-3); font-size: 12px; font-weight: 700; }
|
||||||
|
.workflow-inspector-tabs button.active { border-bottom-color: var(--accent); color: var(--text-bright); }
|
||||||
|
.workflow-rename, .workflow-choice-rule label, .workflow-choice-default label, .workflow-transition-editor label, .workflow-sns-task-picker > label, .workflow-definition-editor > label { display: grid; gap: 5px; color: var(--text-2); font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.workflow-rename { margin: 0 0 18px; }
|
||||||
|
.workflow-rename input, .workflow-choice-rule input, .workflow-choice-rule select, .workflow-choice-default select, .workflow-transition-editor select { width: 100%; min-height: 34px; padding: 0 9px; border: 1px solid var(--input-border); border-radius: 3px; background: var(--input-bg); color: var(--text); font-size: 12px; }
|
||||||
|
.workflow-studio-json pre { margin: 8px 0 0; overflow: auto; color: var(--text); font-size: 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.workflow-state-node { position: relative; min-width: 190px; max-width: 230px; padding: 12px 14px; border: 1px solid var(--workflow-node-border); border-left: 5px solid var(--workflow-node-accent); border-radius: 5px; background: var(--workflow-node-bg); color: var(--workflow-node-text); box-shadow: 0 3px 9px rgba(31, 41, 55, .2); }
|
||||||
|
.workflow-state-node strong, .workflow-state-type, .workflow-retry-marker { display: block; }
|
||||||
|
.workflow-state-node > strong { padding-right: 22px; font-size: 13px; line-height: 1.35; }
|
||||||
|
.workflow-state-type { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; color: var(--workflow-node-muted); font-size: 9px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.workflow-node-delete { position: absolute; z-index: 2; top: 8px; right: 8px; display: grid; width: 21px; height: 21px; place-items: center; border: 0; border-radius: 3px; background: transparent; color: var(--workflow-node-muted); }
|
||||||
|
.workflow-node-delete:hover { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.workflow-choice-branches { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||||
|
.workflow-choice-branches > div { position: relative; min-height: 24px; padding: 5px 13px 5px 6px; border: 1px solid #d69a46; border-radius: 2px; background: #fff8ed; color: #68420d; font-size: 9px; font-weight: 700; }
|
||||||
|
.workflow-choice-branches .react-flow__handle { bottom: -5px; width: 8px; height: 8px; border-color: #945f16; background: #fff; }
|
||||||
|
.workflow-choice-placeholder { display: grid; width: 210px; min-height: 100px; place-content: center; gap: 5px; border: 2px dashed #79879a; border-radius: 5px; background: rgba(255, 255, 255, .78); color: var(--workflow-node-text); text-align: center; box-shadow: 0 2px 6px rgba(31, 41, 55, .08); }
|
||||||
|
.workflow-choice-placeholder:hover, .workflow-choice-placeholder:focus-visible { border-color: #3949ab; background: #fff; }
|
||||||
|
.workflow-choice-placeholder > span { color: #74480b; font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.workflow-choice-placeholder > strong { font-size: 13px; }
|
||||||
|
.workflow-choice-placeholder > small { color: var(--workflow-node-muted); font-size: 9px; }
|
||||||
|
.workflow-empty-guidance { display: grid; gap: 4px; min-width: 190px; padding: 15px; border: 2px dashed var(--workflow-node-border); border-radius: 5px; background: rgba(255, 255, 255, .82); color: var(--workflow-node-text); text-align: center; }
|
||||||
|
.workflow-empty-guidance span { color: var(--workflow-node-muted); font-size: 11px; }
|
||||||
|
.workflow-open-studio { width: calc(100% - 24px); margin: 12px 12px 0; }
|
||||||
|
.workflow-container-node { height: 100%; padding: 10px; border: 1px dashed var(--workflow-container-border); border-radius: 6px; background: var(--workflow-container-bg); color: var(--workflow-node-text); }
|
||||||
|
.workflow-retry-marker { margin-top: 7px; color: #854d0e; font-size: 10px; font-weight: 700; }
|
||||||
|
.workflow-state-name { display: block; margin-top: 4px; color: var(--workflow-node-muted); font-size: 10px; }
|
||||||
|
.workflow-unattached-marker { display: inline-block; margin-top: 7px; padding: 2px 5px; border: 1px solid #a85f13; border-radius: 2px; color: #7a3f00; font-size: 9px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.workflow-state-task { border-left-color: var(--workflow-task); }
|
||||||
|
.workflow-node-lambda { border-left-color: #d97706; }
|
||||||
|
.workflow-node-sns { border-left-color: #c4387b; }
|
||||||
|
.workflow-node-sqs-send-message { border-left-color: #c2415a; }
|
||||||
|
.workflow-node-dynamodb-put-item, .workflow-node-dynamodb-get-item, .workflow-node-dynamodb-update-item, .workflow-node-dynamodb-delete-item { border-left-color: #2f6eb5; }
|
||||||
|
.workflow-node-eventbridge-put-events { border-left-color: #c3347a; }
|
||||||
|
.workflow-node-ecs-run-task, .workflow-node-ecs-run-task-sync { border-left-color: #d46613; }
|
||||||
|
.workflow-node-states-start-execution { border-left-color: #7650b5; }
|
||||||
|
.workflow-state-choice { border-left-color: var(--workflow-choice); border-color: var(--workflow-choice-border); }
|
||||||
|
.workflow-state-pass { border-left-color: var(--workflow-pass); border-color: var(--workflow-pass-border); }
|
||||||
|
.workflow-state-wait { border-left-color: var(--workflow-wait); border-color: var(--workflow-wait-border); }
|
||||||
|
.workflow-state-succeed { border-left-color: #15803d; border-color: #15803d; }
|
||||||
|
.workflow-state-fail { border-left-color: #b91c1c; border-color: #b91c1c; }
|
||||||
|
.workflow-state-parallel { border-left-color: var(--workflow-parallel); border-color: var(--workflow-parallel-border); }
|
||||||
|
.workflow-state-map { border-left-color: var(--workflow-map); border-color: var(--workflow-map-border); }
|
||||||
|
.workflow-state-unattached { border-color: #a85f13; }
|
||||||
|
.workflow-boundary-node { display: flex; align-items: center; justify-content: center; gap: 6px; min-width: 104px; padding: 8px 12px; border: 1px solid; border-radius: 999px; background: #fff; box-shadow: 0 2px 6px rgba(31, 41, 55, .18); font-size: 12px; font-weight: 700; }
|
||||||
|
.workflow-boundary-start { border-color: #24795f; color: #145a45; }
|
||||||
|
.workflow-boundary-end { border-color: #a63b48; color: #862a35; }
|
||||||
|
.workflow-validation { display: flex; align-items: baseline; gap: 9px; margin: 8px 16px; padding: 8px 11px; border: 1px solid var(--workflow-validation-border); border-radius: 3px; background: var(--workflow-validation-bg); color: var(--workflow-validation-text); font-size: 11px; }
|
||||||
|
.workflow-validation-errors { border-color: var(--status-error); color: var(--status-error); }
|
||||||
|
.workflow-confirmation { display: grid; gap: 6px; margin: 10px 16px; padding: 9px 11px; border: 1px solid var(--workflow-confirmation-border); border-radius: 3px; background: var(--workflow-confirmation-bg); color: var(--text-2); font-size: 12px; }
|
||||||
|
.workflow-confirmation strong { color: var(--text); }
|
||||||
|
.workflow-confirmation > div { display: flex; gap: 6px; }
|
||||||
|
.workflow-lambda-picker { display: grid; gap: 5px; margin: 4px 0 14px; }
|
||||||
|
.workflow-lambda-picker label { color: var(--text-2); font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.workflow-lambda-picker select, .workflow-task-rule input, .workflow-task-rule select, .workflow-sns-task-picker select { width: 100%; min-height: 32px; border: 1px solid var(--input-border); border-radius: 3px; background: var(--input-bg); color: var(--text); }
|
||||||
|
.workflow-task-editor { display: grid; gap: 10px; margin: 4px 0 14px; padding: 10px; border: 1px solid var(--border); border-radius: 3px; background: var(--connection-bg); }
|
||||||
|
.workflow-task-editor h4 { margin: 0; color: var(--text); font-size: 13px; }
|
||||||
|
.workflow-service-editor > label, .workflow-integration-entry > label { display: grid; min-width: 0; gap: 5px; color: var(--text-2); font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.workflow-service-editor input, .workflow-service-editor select, .workflow-service-editor textarea { width: 100%; min-width: 0; min-height: 34px; padding: 6px 9px; border: 1px solid var(--input-border); border-radius: 3px; background: var(--input-bg); color: var(--text); font-size: 12px; }
|
||||||
|
.workflow-service-editor textarea { min-height: 88px; resize: vertical; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; text-transform: none; }
|
||||||
|
.workflow-service-editor .workflow-checkbox { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.workflow-service-editor .workflow-checkbox input { width: auto; min-height: 0; }
|
||||||
|
.workflow-integration-entry { display: grid; min-width: 0; gap: 8px; padding: 9px; border: 1px solid var(--border); border-radius: 3px; }
|
||||||
|
.workflow-integration-entry > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--text-2); font-size: 11px; }
|
||||||
|
.workflow-task-section { display: grid; gap: 7px; }
|
||||||
|
.workflow-task-section > div { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; color: var(--text-2); font-size: 11px; }
|
||||||
|
.workflow-task-rule { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 8px; border: 1px solid var(--border); border-radius: 3px; }
|
||||||
|
.workflow-task-rule label { display: grid; gap: 3px; color: var(--text-3); font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.workflow-task-rule label:first-child { grid-column: 1 / -1; }
|
||||||
|
.workflow-task-rule .button { grid-column: 1 / -1; justify-self: start; }
|
||||||
|
.workflow-choice-editor { display: grid; gap: 10px; }
|
||||||
|
.workflow-inspector-section-title h4 { margin: 0; color: var(--text-bright); font-size: 13px; }
|
||||||
|
.workflow-inspector-section-title span { color: var(--text-3); font-size: 10px; }
|
||||||
|
.workflow-choice-rule { display: grid; gap: 9px; padding: 12px; border: 1px solid var(--border); border-radius: 3px; background: var(--connection-bg); }
|
||||||
|
.workflow-choice-rule > div, .workflow-choice-default > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.workflow-choice-rule > div > *, .workflow-choice-default > div > * { min-width: 0; }
|
||||||
|
.workflow-choice-rule > div strong, .workflow-choice-default strong { color: var(--text); font-size: 11px; }
|
||||||
|
.workflow-choice-rule > div span, .workflow-choice-default span { color: var(--text-3); font-size: 10px; }
|
||||||
|
.workflow-add-rule { justify-self: start; }
|
||||||
|
.workflow-choice-default { display: grid; gap: 7px; padding: 12px; border: 1px solid var(--border); border-radius: 3px; }
|
||||||
|
.workflow-transition-editor { display: grid; gap: 8px; margin: 0 0 14px; padding: 10px; border: 1px solid var(--border); border-radius: 3px; background: var(--connection-bg); }
|
||||||
|
.workflow-transition-editor h4 { margin: 0; color: var(--text); font-size: 13px; }
|
||||||
|
.workflow-definition-editor { display: grid; gap: 9px; min-height: 460px; }
|
||||||
|
.workflow-definition-editor textarea { width: 100%; min-height: 400px; resize: vertical; border: 1px solid var(--input-border); border-radius: 3px; background: var(--input-bg); color: var(--text); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; tab-size: 2; }
|
||||||
|
.workflow-definition-editor .button { justify-self: start; }
|
||||||
|
.workflow-studio-canvas .react-flow__edge-path { stroke: var(--workflow-edge); stroke-width: 1.6; }
|
||||||
|
.workflow-studio-canvas .react-flow__edge-text { fill: #283447; font-size: 10px; font-weight: 700; }
|
||||||
|
.workflow-studio-canvas .react-flow__edge-textbg { fill: var(--workflow-canvas-bg); }
|
||||||
|
.workflow-edge-label { position: absolute; z-index: 3; display: flex; max-width: 145px; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid #c6ccd6; border-radius: 999px; background: var(--surface); color: var(--text); font-size: 10px; font-weight: 700; line-height: 1.25; text-align: center; box-shadow: 0 1px 4px rgba(31, 41, 55, .14); pointer-events: all; overflow-wrap: anywhere; }
|
||||||
|
.workflow-edge-label span { min-width: 0; }
|
||||||
|
.workflow-edge-splice b { display: grid; width: 17px; height: 17px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: var(--accent); color: #fff; font-size: 14px; line-height: 1; opacity: .72; }
|
||||||
|
.workflow-edge-splice:hover, .workflow-edge-splice.drag-active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint); }
|
||||||
|
.workflow-edge-splice:hover b, .workflow-edge-splice.drag-active b { opacity: 1; }
|
||||||
|
.workflow-catch-edge .react-flow__edge-path { stroke: #b91c1c; stroke-dasharray: 5 4; }
|
||||||
|
.workflow-placeholder-edge .react-flow__edge-path { stroke-dasharray: 5 4; }
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.workflow-studio-split { grid-template-columns: 220px minmax(400px, 1fr) 300px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.workflow-studio-page, .workflow-studio { height: auto; min-height: 100%; }
|
||||||
|
.workflow-studio-workspace { overflow: auto; }
|
||||||
|
.workflow-studio-split { grid-template-columns: 1fr; }
|
||||||
|
.workflow-action-palette { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.workflow-palette-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.workflow-studio-canvas { min-height: 620px; }
|
||||||
|
.workflow-studio-json { border-top: 1px solid var(--border); border-left: 0; }
|
||||||
|
.workflow-task-rule { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
.widget-header {
|
.widget-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1608,6 +1846,62 @@ pre,
|
|||||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-create-modes {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-create-mode {
|
||||||
|
align-items: flex-start;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text-2);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-create-mode:hover,
|
||||||
|
.workflow-create-mode.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-tint-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-create-mode > svg {
|
||||||
|
color: var(--accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-create-mode strong,
|
||||||
|
.workflow-create-mode small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-create-mode strong {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-create-mode small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-definition-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 230px;
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
.serverless-payload-editor {
|
.serverless-payload-editor {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -3655,6 +3949,10 @@ button.console-service-card {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-create-modes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-inspector {
|
.resource-inspector {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {describe, expect, test} from 'vitest'
|
||||||
|
import {validateAslDefinition, workflowCreateSubmission, workflowCreationDestinationPath} from './workflowCreate'
|
||||||
|
|
||||||
|
describe('workflowCreateSubmission', () => {
|
||||||
|
test('keeps navigation metadata outside API values', () => {
|
||||||
|
expect(workflowCreateSubmission(' orders ', 'studio')).toEqual({
|
||||||
|
values: {name: 'orders'},
|
||||||
|
destination: 'studio',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves an explicit definition exactly', () => {
|
||||||
|
const definition = ' {"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}\n'
|
||||||
|
|
||||||
|
expect(workflowCreateSubmission('orders', 'explorer', definition)).toEqual({
|
||||||
|
values: {name: 'orders', definition},
|
||||||
|
destination: 'explorer',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('workflowCreationDestinationPath', () => {
|
||||||
|
test('opens Studio only for the Studio creation mode', () => {
|
||||||
|
const arn = 'arn:aws:states:us-east-1:000000000000:stateMachine:orders'
|
||||||
|
|
||||||
|
expect(workflowCreationDestinationPath('studio', arn)).toBe(`/cloud-explorer/aws/workflows/${encodeURIComponent(arn)}/studio`)
|
||||||
|
expect(workflowCreationDestinationPath('explorer', arn)).toBeNull()
|
||||||
|
expect(workflowCreationDestinationPath(undefined, arn)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateAslDefinition', () => {
|
||||||
|
test('accepts an ASL object with a string StartAt and object States', () => {
|
||||||
|
expect(validateAslDefinition('{"StartAt":"Done","States":{}}')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[' ', 'ASL definition is required.'],
|
||||||
|
['{', 'ASL definition must be valid JSON.'],
|
||||||
|
['null', 'ASL definition must be a JSON object.'],
|
||||||
|
['{"StartAt":false,"States":{}}', 'ASL definition must include StartAt as a string.'],
|
||||||
|
['{"StartAt":"Done","States":null}', 'ASL definition must include States as an object.'],
|
||||||
|
])('rejects %s', (definition, message) => {
|
||||||
|
expect(validateAslDefinition(definition)).toBe(message)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {workflowStudioPath} from './workflowStudio'
|
||||||
|
|
||||||
|
export type WorkflowCreationDestination = 'studio' | 'explorer'
|
||||||
|
|
||||||
|
export interface WorkflowCreateSubmission {
|
||||||
|
values: Record<string, unknown>
|
||||||
|
destination: WorkflowCreationDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workflowCreateSubmission(name: string, destination: WorkflowCreationDestination, definition?: string): WorkflowCreateSubmission {
|
||||||
|
return {
|
||||||
|
values: definition === undefined ? {name: name.trim()} : {name: name.trim(), definition},
|
||||||
|
destination,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workflowCreationDestinationPath(destination: WorkflowCreationDestination | undefined, resourceId: string): string | null {
|
||||||
|
return destination === 'studio' ? workflowStudioPath(resourceId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAslDefinition(definition: string): string | null {
|
||||||
|
if (!definition.trim()) return 'ASL definition is required.'
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(definition)
|
||||||
|
} catch {
|
||||||
|
return 'ASL definition must be valid JSON.'
|
||||||
|
}
|
||||||
|
if (!isObject(parsed)) return 'ASL definition must be a JSON object.'
|
||||||
|
if (typeof parsed.StartAt !== 'string') return 'ASL definition must include StartAt as a string.'
|
||||||
|
if (!isObject(parsed.States)) return 'ASL definition must include States as an object.'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {describe, expect, test} from 'vitest'
|
||||||
|
import {workflowStudioPath} from '@/lib/workflowStudio'
|
||||||
|
|
||||||
|
describe('workflowStudioPath', () => {
|
||||||
|
test('encodes a state machine ARN as one route parameter', () => {
|
||||||
|
expect(workflowStudioPath('arn:aws:states:us-east-1:000000000000:stateMachine:orders')).toBe('/cloud-explorer/aws/workflows/arn%3Aaws%3Astates%3Aus-east-1%3A000000000000%3AstateMachine%3Aorders/studio')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function workflowStudioPath(resourceId: string): string {
|
||||||
|
return `/cloud-explorer/aws/workflows/${encodeURIComponent(resourceId)}/studio`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {ArrowLeft, Workflow} from 'lucide-react'
|
||||||
|
import {Link, useParams} from 'react-router-dom'
|
||||||
|
import {useCloudResourceQuery} from '@/api/queries/cloudQueries'
|
||||||
|
import {WorkflowStudio} from '@/components/WorkflowStudio'
|
||||||
|
|
||||||
|
export function WorkflowStudioPage() {
|
||||||
|
const {resourceId} = useParams()
|
||||||
|
const resourceQuery = useCloudResourceQuery('aws', 'workflows', resourceId)
|
||||||
|
|
||||||
|
return <div className="workflow-studio-page">
|
||||||
|
<header className="page-header data-workspace-header">
|
||||||
|
<Link className="button compact" to="/cloud-explorer/aws/workflows"><ArrowLeft size={14}/>Back to workflows</Link>
|
||||||
|
<div className="page-title">
|
||||||
|
<Workflow size={20}/>
|
||||||
|
<div><h1>{resourceQuery.data?.name ?? resourceId ?? 'Workflow Studio'}</h1><p className="muted">AWS Step Functions · Workflow Studio</p></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="content workflow-studio-workspace">
|
||||||
|
{resourceQuery.isError && <p className="error-text" role="alert">Unable to load state machine: {resourceQuery.error instanceof Error ? resourceQuery.error.message : 'Unknown error.'}</p>}
|
||||||
|
{resourceQuery.isLoading && <p className="muted" role="status">Loading state machine.</p>}
|
||||||
|
{resourceQuery.data && <WorkflowStudio resource={resourceQuery.data}/>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
Generated
+184
-1
@@ -110,6 +110,9 @@ importers:
|
|||||||
'@tanstack/react-query-devtools':
|
'@tanstack/react-query-devtools':
|
||||||
specifier: ^5.101.2
|
specifier: ^5.101.2
|
||||||
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
|
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.10.1
|
||||||
|
version: 12.12.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1263,6 +1266,24 @@ packages:
|
|||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.12':
|
||||||
|
resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -1400,6 +1421,22 @@ packages:
|
|||||||
'@vitest/utils@4.1.11':
|
'@vitest/utils@4.1.11':
|
||||||
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
||||||
|
|
||||||
|
'@xyflow/react@12.12.0':
|
||||||
|
resolution: {integrity: sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=17'
|
||||||
|
'@types/react-dom': '>=17'
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.83':
|
||||||
|
resolution: {integrity: sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==}
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
@@ -1500,6 +1537,9 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1529,6 +1569,44 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-transition@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
data-urls@7.0.0:
|
data-urls@7.0.0:
|
||||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -2484,6 +2562,21 @@ packages:
|
|||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
zustand@5.0.14:
|
zustand@5.0.14:
|
||||||
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -4330,6 +4423,27 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.12': {}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1': {}
|
'@types/esrecurse@4.3.1': {}
|
||||||
@@ -4513,6 +4627,31 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.1
|
tinyrainbow: 3.1.1
|
||||||
|
|
||||||
|
'@xyflow/react@12.12.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.83
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.83':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
'@types/d3-transition': 3.0.9
|
||||||
|
'@types/d3-zoom': 3.0.8
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
@@ -4607,6 +4746,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
@@ -4630,6 +4771,42 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
data-urls@7.0.0:
|
data-urls@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -5369,7 +5546,6 @@ snapshots:
|
|||||||
use-sync-external-store@1.6.0(react@19.2.7):
|
use-sync-external-store@1.6.0(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
optional: true
|
|
||||||
|
|
||||||
vite@8.1.3(@types/node@26.1.2)(jiti@1.21.7):
|
vite@8.1.3(@types/node@26.1.2)(jiti@1.21.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5466,6 +5642,13 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.4.3: {}
|
zod@4.4.3: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.2.17)(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)):
|
zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|||||||
Reference in New Issue
Block a user