diff --git a/aws-schema.png b/aws-schema.png new file mode 100644 index 0000000..fef8f37 Binary files /dev/null and b/aws-schema.png differ diff --git a/docker/Dockerfile b/docker/Dockerfile index fd396f2..11d41f9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,11 +1,14 @@ # Stage 1 — build frontend FROM node:20-alpine AS frontend-build WORKDIR /app -COPY packages/frontend/package.json ./ -RUN npm install -COPY packages/frontend/ . -COPY OS_img/ /OS_img/ -RUN npm run build +RUN corepack enable && corepack prepare pnpm@9.2.0 --activate +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/api/package.json packages/api/package.json +COPY packages/frontend/package.json packages/frontend/package.json +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 FROM oven/bun:1-alpine AS api-build @@ -20,7 +23,7 @@ FROM alpine:3 RUN apk add --no-cache ca-certificates libstdc++ WORKDIR /app 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 EXPOSE 4500 CMD ["./server"] diff --git a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts index c958548..183ed7d 100644 --- a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts @@ -9,9 +9,11 @@ import { ListStateMachinesCommand, StartExecutionCommand, StopExecutionCommand, + UpdateStateMachineCommand, type SFNClient, } from '@aws-sdk/client-sfn' import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter' +import {mapAwsSdkError} from './awsErrors' import {ValidationError} from '../cloud-spi/errors' type SendResult = Record @@ -177,13 +179,32 @@ describe('AwsStepFunctionsAdapter', () => { 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 cases: Array<[Record, string]> = [ [{}, 'name is required'], - [{name: 'orders'}, 'roleArn is required'], - [{name: 'orders', roleArn: ROLE}, 'definition is required'], + [{name: 'orders', definition: ''}, 'definition is required'], ] 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 // a parser message; naming the missing field is more actionable. const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) 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( - 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) }) + 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 () => { const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1' const {client, sent} = stubSfn((command) => { diff --git a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts index c69d6a0..82187ce 100644 --- a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts @@ -8,6 +8,7 @@ import { ListStateMachinesCommand, StartExecutionCommand, StopExecutionCommand, + UpdateStateMachineCommand, type SFNClient, type StateMachineListItem, } from '@aws-sdk/client-sfn' @@ -20,6 +21,7 @@ import type { CreateResourceInput, ResourceQuery, ServiceSchema, + UpdateResourceInput, } 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', ) } - 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)) { 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 arn = res.stateMachineArn @@ -111,6 +113,15 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter { await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id})) } + async update(id: string, input: UpdateResourceInput): Promise { + 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}> { const result = await this.sfn.send(new StartExecutionCommand({ stateMachineArn, @@ -220,21 +231,24 @@ function filterBySearch(resources: CloudResource[], search?: string): CloudResou * with a JSON parser message; naming the missing field is more actionable. */ 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 try { - parsed = JSON.parse(raw) + parsed = JSON.parse(value) } catch { throw new ValidationError('definition must be valid JSON') } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new ValidationError('definition must be a JSON object') } - for (const field of ['StartAt', 'States'] as const) { - if (!(field in parsed)) throw new ValidationError(`definition must contain ${field}`) + const definition = parsed as Record + 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 { @@ -259,3 +273,12 @@ function requiredString(value: unknown, field: string): string { if (!trimmed) throw new ValidationError(`${field} is required`) 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}}}) +} diff --git a/packages/api/src/cloud-spi/workflowsSchema.test.ts b/packages/api/src/cloud-spi/workflowsSchema.test.ts new file mode 100644 index 0000000..441adfb --- /dev/null +++ b/packages/api/src/cloud-spi/workflowsSchema.test.ts @@ -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, + }) + }) +}) diff --git a/packages/api/src/cloud-spi/workflowsSchema.ts b/packages/api/src/cloud-spi/workflowsSchema.ts index b73eec7..9b29219 100644 --- a/packages/api/src/cloud-spi/workflowsSchema.ts +++ b/packages/api/src/cloud-spi/workflowsSchema.ts @@ -40,29 +40,17 @@ export function awsWorkflowsSchema(): ServiceSchema { 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', - label: 'Definition (Amazon States Language)', + label: 'ASL Definition', type: 'textarea', - required: true, + required: false, + group: 'Optional', span: true, - group: 'Required', - description: 'JSON with StartAt and States, e.g. {"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}', + description: 'Complete Amazon States Language JSON. When omitted, a valid starter workflow is created.', }, ], - actions: ['list', 'create', 'delete', 'inspect'], + actions: ['list', 'create', 'update', 'delete', 'inspect'], capabilities: { resourceActions: [ {name: 'list', label: 'List state machines', enabled: true, status: 'available', runtimeRequired: true}, @@ -74,6 +62,7 @@ export function awsWorkflowsSchema(): ServiceSchema { status: 'available', runtimeRequired: true, }, + {name: 'update', label: 'Update definition', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true}, ], }, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 819f552..0f777d8 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -17,6 +17,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^4.1.0", diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 7596388..d0a08ed 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage' import {CloudExplorerPage} from '@/pages/CloudExplorerPage' import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage' import {DatabaseDataPage} from '@/pages/DatabaseDataPage' +import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage' export default function App() { return ( @@ -17,6 +18,7 @@ export default function App() { }/> }/> }/> + }/> }/> }/> diff --git a/packages/frontend/src/api/queries/cloudQueries.ts b/packages/frontend/src/api/queries/cloudQueries.ts index ac37b35..3e0715f 100644 --- a/packages/frontend/src/api/queries/cloudQueries.ts +++ b/packages/frontend/src/api/queries/cloudQueries.ts @@ -1,5 +1,6 @@ import {useQuery} from '@tanstack/react-query' import { + getCloudResource, getCloudServiceStatus, getCloudStatus, listCloudResources, @@ -23,6 +24,8 @@ export const cloudQueryKeys = { ['cloud-service-status', cloud, service] as const, resources: (cloud: CloudProvider, service: CloudServiceType) => ['cloud-console-resources', cloud, service] as const, + resource: (cloud: CloudProvider, service: CloudServiceType, id: string) => + ['cloud-resource', cloud, service, id] as const, } 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 { return services?.some((item) => item.service === service && item.availability === 'available') ?? false } diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 7dd9718..c839873 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -59,6 +59,10 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; 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 { cloud: CloudProvider; @@ -71,6 +75,11 @@ interface DynamicResourceViewProps { onOpenInfo: () => void; } +interface CreateMutationVariables { + values: Record; + destination?: WorkflowCreationDestination; +} + export function DynamicResourceView({ cloud, service, @@ -80,6 +89,7 @@ export function DynamicResourceView({ statusLoading = false, onOpenInfo, }: DynamicResourceViewProps) { + const navigate = useNavigate(); const qc = useQueryClient(); const [search, setSearch] = useState(""); const [state, setState] = useState(""); @@ -166,7 +176,7 @@ export function DynamicResourceView({ }; const createMut = useMutation({ - mutationFn: async (values: Record) => { + mutationFn: async ({values}: CreateMutationVariables) => { const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : ""; const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : ""; const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : ""; @@ -254,12 +264,16 @@ export function DynamicResourceView({ throw error; } }, - onSuccess: (resource) => { + onSuccess: (resource, variables) => { setSelected(resource); setCreateOpen(false); void qc.invalidateQueries({ 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} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} - onSubmit={(values) => createMut.mutate(values)} + onSubmit={(values) => createMut.mutate({values})} /> ) : service === "loadbalancing" && cloud === "aws" ? ( setCreateOpen(false)} - onSubmit={(values) => createMut.mutate(values)} + onSubmit={(values) => createMut.mutate({values})} + /> + ) : service === "workflows" && cloud === "aws" ? ( + setCreateOpen(false)} + onSubmit={(submission) => createMut.mutate(submission)} /> ) : ( setCreateOpen(false)} - onSubmit={(values) => createMut.mutate(values)} + onSubmit={(values) => createMut.mutate({values})} /> )} @@ -840,13 +861,16 @@ export function DynamicResourceView({ )} {activeSelected && !showDatabaseSnapshots && ( - +
+ {cloud === "aws" && service === "workflows" && } + +
)} {service === "storage" && ( diff --git a/packages/frontend/src/components/WorkflowCreatePanel.test.tsx b/packages/frontend/src/components/WorkflowCreatePanel.test.tsx new file mode 100644 index 0000000..a3a4e8f --- /dev/null +++ b/packages/frontend/src/components/WorkflowCreatePanel.test.tsx @@ -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() + 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() + }) +}) diff --git a/packages/frontend/src/components/WorkflowCreatePanel.tsx b/packages/frontend/src/components/WorkflowCreatePanel.tsx new file mode 100644 index 0000000..9818b9a --- /dev/null +++ b/packages/frontend/src/components/WorkflowCreatePanel.tsx @@ -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('studio') + const [name, setName] = useState('') + const [definition, setDefinition] = useState('') + const [definitionError, setDefinitionError] = useState(null) + const validName = /^[a-zA-Z0-9_-]{1,80}$/.test(name.trim()) + + return
{ + 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')) + }}> +
+ + +
+ + {mode === 'asl' &&