diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 56f8e78df7..2dcf4d6c2a 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -9,6 +9,7 @@ services: depends_on: ['postgres'] environment: HASURA_GRAPHQL_ADMIN_SECRET: '${HASURA_GRAPHQL_ADMIN_SECRET}' + HASURA_GRAPHQL_JWT_SECRET: '${HASURA_GRAPHQL_JWT_SECRET}' LOG_FILE: console LOG_LEVEL: debug MERLIN_GRAPHQL_URL: http://hasura:8080/v1/graphql diff --git a/e2e-tests/data/aerie-action-demo.js b/e2e-tests/data/aerie-action-demo.js index 67d96ae09c..05ba4c3e41 100644 --- a/e2e-tests/data/aerie-action-demo.js +++ b/e2e-tests/data/aerie-action-demo.js @@ -1,68 +1,318 @@ 'use strict'; -// Define schemas for your action's settings and parameters const parameterDefinitions = { - boolean: { type: "boolean" }, - delay: { type:"int" }, - duration: { type: "duration" }, - file: { type: "file" }, - fileList: { type: "fileList" }, - real: { type: "real" }, - repository: { type: "string" }, - series: { items:{ type: "string" }, type: "series", }, - string: { type: "string" }, - sequence: { type: "sequence" }, - sequenceList: { type: "sequenceList" }, - variant: { type: "variant", variants: [{key: "foo", label: "Foo"}, {key: "bar", label: "Bar"}] }, + logCount: { + type: 'int', + description: 'Number of log lines to generate (0 for none)', + defaultValue: 10, + }, + mode: { + type: 'variant', + description: 'What the action should do', + variants: [ + { key: 'fetch', label: 'Fetch URL' }, + { key: 'adaptation', label: 'Translate File using Adaptation' }, + { key: 'files', label: 'List & Read Files' }, + { key: 'write', label: 'Write a File' }, + { key: 'error', label: 'Throw an Error' }, + { key: 'slow', label: 'Slow (5s delay)' }, + ], + }, + outputFile: { + type: 'string', + description: 'Filename to write (used in "write" mode)', + defaultValue: 'action_output.txt', + }, + outputContent: { + type: 'string', + description: 'Content to write to the file (used in "write" mode)', + defaultValue: 'Hello from aerie-action-demo!', + }, + secret: { + type: 'secret', + description: 'A secret value (e.g. API token) — sent securely, never stored in run history', + }, + sequence: { + type: 'sequence', + description: 'A sequence file parameter (for testing file pickers)', + primary: true, + }, }; + const settingDefinitions = { - externalUrl: { type: "string" }, - retries: { type: "int" } + externalUrl: { + type: 'string', + description: 'Base URL for fetch mode', + defaultValue: 'https://api.github.com', + }, + secretSetting: { + type: 'secret', + description: 'A secret value (e.g. API token) — sent securely, never stored in run history', + }, + files: { + type: 'fileList', + description: 'A list of files to process', + }, + verbose: { + type: 'string', + description: 'Enable extra-verbose logging', + defaultValue: "false", + }, }; + async function main(actionParameters, actionSettings, actionsAPI) { - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, actionParameters.delay ?? 0); - }) - - const url = `${actionSettings.externalUrl}/${actionParameters.repository}`; - const startTime = performance.now(); - // Make a request to an external URL using fetch - const result = await fetch(url, { - method: "get", - headers: { - "Content-Type": "application/json", - }, + const { logCount = 10, mode = 'fetch', outputFile, outputContent, secret, sequence } = actionParameters; + const { externalUrl = 'https://api.github.com', verbose = false } = actionSettings; + const startTime = performance.now(); + + // Generate requested log output + emitLogs(logCount, verbose); + + console.log(`Action started — mode: ${mode}`); + console.log(`Secret provided: ${secret ? 'yes (' + secret.length + ' chars)' : 'no'}`); + console.log(`Parameters: ${JSON.stringify({ ...actionParameters, secret: secret ? '***' : undefined })}`); + + let result; + switch (mode) { + case 'fetch': + result = await runFetchMode(externalUrl, verbose); + break; + case 'files': + result = await runFilesMode(actionsAPI, sequence, verbose); + break; + case 'adaptation': + result = await runAdaptationMode(actionsAPI, sequence); + break; + case 'write': + result = await runWriteMode(actionsAPI, outputFile, outputContent, verbose); + break; + case 'error': + result = runErrorMode(); + break; + case 'slow': + result = await runSlowMode(verbose); + break; + default: + result = { status: 'FAILED', data: { error: `Unknown mode: ${mode}` } }; + } + + const elapsed = (performance.now() - startTime).toFixed(1); + console.log(`Action completed in ${elapsed}ms with status: ${result.status}`); + + return result; +} + +function emitLogs(count, verbose) { + if (count <= 0) return; + + const messages = [ + 'Initializing action runtime...', + 'Loading workspace configuration...', + 'Validating parameter schemas...', + 'Resolving file dependencies...', + 'Checking network connectivity...', + 'Authenticating with external service...', + 'Preparing execution context...', + 'Allocating resources...', + 'Starting main execution loop...', + 'Processing batch 1 of N...', + 'Intermediate checkpoint reached.', + 'Cache hit for resource lookup.', + 'Cache miss — fetching from remote.', + 'Retrying transient operation (attempt 2/3)...', + 'Rate limit approaching — throttling requests.', + 'Partial result received, continuing...', + 'Unexpected response format — attempting fallback parse.', + 'File handle released.', + 'Connection pool drained to 1 active.', + 'Garbage collection triggered.', + 'Serializing intermediate results...', + 'Compressing output payload...', + 'Flushing write buffer...', + 'Waiting for async callbacks...', + 'Finalizing transaction log...', + ]; + + for (let i = 0; i < count; i++) { + const msg = messages[i % messages.length]; + if (verbose) { + const ts = new Date().toISOString(); + console.log(`[${ts}] [line ${i + 1}/${count}] ${msg}`); + } else { + console.log(msg); + } + } +} + +async function runFetchMode(externalUrl, verbose) { + const url = `${externalUrl}/repos/NASA-AMMOS/plandev`; + console.log(`Fetching: ${url}`); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, }); - console.log(`request took ${performance.now() - startTime}ms`); - // try parsing result as either json or text - let resultData; + + if (verbose) { + console.log(`Response status: ${response.status} ${response.statusText}`); + } + + let data; try { - resultData = await result.clone().json(); + data = await response.clone().json(); + } catch { + data = await response.clone().text(); + } + + if (!response.ok) { + console.warn(`Non-OK response: ${response.status}`); + return { status: 'FAILED', data }; + } + + // Return a curated subset so results aren't enormous + const summary = { + description: data.description, + forks: data.forks_count, + language: data.language, + name: data.full_name, + open_issues: data.open_issues_count, + stars: data.stargazers_count, + updated_at: data.updated_at, + url: data.html_url, + }; + + return { status: 'SUCCESS', data: summary }; + } catch (err) { + console.error(`Fetch failed: ${err.message}`); + return { status: 'FAILED', data: { error: err.message } }; + } +} + +async function runFilesMode(actionsAPI, sequence, verbose) { + console.log('Listing workspace files...'); + try { + // For now treat files as a JSON string that needs parsing. This should be fixed in the API to return an array directly. + const filesString = await actionsAPI.listFiles('.'); + const files = JSON.parse(filesString); + console.log(`Found ${files.length} files`); + + if (verbose) { + files.forEach((f, i) => console.log(` [${i}] ${f}`)); } - catch { - resultData = await result.clone().text(); + + let sequenceContent = null; + if (sequence) { + console.log(`Reading sequence file: ${sequence}`); + try { + sequenceContent = await actionsAPI.readFile(sequence); + console.log(`Sequence file read successfully (${sequenceContent.length} chars)`); + } catch (err) { + console.warn(`Could not read sequence file: ${err.message}`); + } } - try { - // read/write files using the actions helpers - const files = await actionsAPI.listSequences(); - const myFile = await actionsAPI.readSequence("my_file"); - const writeResult = await actionsAPI.writeSequence("new_file", "new contents"); - console.log(`writeResult: ${JSON.stringify(writeResult)}`); - console.log('sequence files:', JSON.stringify(files)); - console.log(`myFile: ${JSON.stringify(myFile)}`); - } catch (error) { - console.log(error) + + return { + status: 'SUCCESS', + data: { + fileCount: files.length, + files: files.slice(0, 20), + sequenceContent: sequenceContent ? sequenceContent.substring(0, 500) : null, + sequenceFile: sequence || null, + }, + }; + } catch (err) { + console.error(`File listing failed: ${err.message}`); + return { status: 'FAILED', data: { error: err.message } }; + } +} + +async function runWriteMode(actionsAPI, filename, content, verbose) { + const name = filename || 'action_output.txt'; + const body = content || 'Written by aerie-action-demo'; + + console.log(`Writing file: ${name}`); + if (verbose) { + console.log(`Content length: ${body.length} chars`); + } + + try { + const result = await actionsAPI.writeFile(name, body); + console.log('File written successfully'); + return { + status: 'SUCCESS', + data: { filename: name, contentLength: body.length, writeResult: result }, + }; + } catch (err) { + console.error(`Write failed: ${err.message}`); + return { status: 'FAILED', data: { error: err.message } }; + } +} + + +async function runAdaptationMode(actionsAPI, sequence) { + console.log('Loading adaptation'); + + try { + const adaptation = await actionsAPI.loadAdaptation(); + console.log('Adaptation loaded'); + + if (!sequence) { + throw new Error('No sequence file provided for adaptation mode'); } + + console.log(`Reading sequence file: ${sequence}`); + const sequenceContent = await actionsAPI.readFile(sequence); + console.log(`Sequence file read successfully (${sequenceContent.length} chars)`); + const translated = adaptation.outputs[0].toOutputFormat( + sequenceContent, + { commandDictionary: null, channelDictionary: null, parameterDictionaries: [], librarySequences: [] }, + 'my-sequence' + ); + console.log(`Sequence translated successfully (${translated.length} chars)`); + return { - status: "SUCCESS", - data: resultData, + status: 'SUCCESS', + data: { translated: JSON.parse(translated) }, }; + } catch (err) { + console.error(`Translate sequence with adaptation failed: ${err.message}`); + return { status: 'FAILED', data: { error: err.message } }; + } +} + +function runErrorMode() { + console.log('Error mode selected — about to throw'); + console.warn('This is intentional for testing error display'); + throw new Error( + 'Intentional error from aerie-action-demo (error mode).\n' + + 'This tests the error display in the action run detail view.\n' + + 'Stack trace should appear below.' + ); +} + +async function runSlowMode(verbose) { + const totalMs = 5000; + const steps = 5; + const stepMs = totalMs / steps; + + console.log(`Slow mode: running ${steps} steps over ${totalMs}ms`); + + for (let i = 1; i <= steps; i++) { + await new Promise(resolve => setTimeout(resolve, stepMs)); + const pct = Math.round((i / steps) * 100); + console.log(`Step ${i}/${steps} complete (${pct}%)`); + if (verbose) { + console.log(` Elapsed: ${i * stepMs}ms`); + } + } + + return { + status: 'SUCCESS', + data: { message: 'Slow operation completed', durationMs: totalMs, steps }, + }; } exports.main = main; exports.parameterDefinitions = parameterDefinitions; exports.settingDefinitions = settingDefinitions; - - diff --git a/e2e-tests/fixtures/Action.ts b/e2e-tests/fixtures/Action.ts index 6658228afa..a850e402a5 100644 --- a/e2e-tests/fixtures/Action.ts +++ b/e2e-tests/fixtures/Action.ts @@ -3,17 +3,12 @@ import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names- import { setFileInputByFilepath } from '../utilities/helpers'; export class Action { - actionDefinitionButton: Locator; actionDescription: string = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }); - actionFormDescription: Locator; - actionFormName: Locator; - actionFormPath: Locator; actionName: string = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }); actionPath: string = 'e2e-tests/data/aerie-action-demo.js'; + actionsSidebarTab: Locator; createActionButton: Locator; createModal: Locator; - createModalDeleteButton: Locator; - runModal: Locator; constructor( public page: Page, @@ -22,14 +17,30 @@ export class Action { this.updatePage(page); } + async archiveAction(): Promise { + // Navigate to Configure tab and click Archive Action + await this.page.getByRole('tab', { name: 'Configure' }).click(); + await this.page.getByRole('button', { name: 'Archive Action' }).click(); + // Confirm the archive modal + const confirmModal = this.page.locator('#modal-container'); + await expect(confirmModal).toBeVisible(); + await confirmModal.getByRole('button', { name: 'Archive' }).click(); + // Verify the Archived badge appears in the detail view header + await expect(this.page.getByText('Archived', { exact: true })).toBeVisible(); + // Verify Run Action button is disabled + await expect(this.page.getByRole('button', { name: 'Run Action' })).toBeDisabled(); + } + async configureAction(): Promise { await this.page.getByRole('tab', { name: 'Configure' }).click(); - // Provide the github api url to the action found in `actionPath` so that it can - // successfully query the api - await this.page - .locator(".configure .parameter-base-string:has-text('externalUrl') input") - .fill('https://api.github.com/'); - await this.page.getByRole('button', { name: 'Save' }).click(); + // Fill in the externalUrl setting and blur to trigger change event + const externalUrlInput = this.page.locator(".parameter-base-string:has-text('externalUrl') input"); + await externalUrlInput.fill('https://api.github.com/'); + await externalUrlInput.dispatchEvent('change'); + // Wait for Save button to become enabled (isDirty must be true) + const saveButton = this.page.getByRole('button', { name: 'Save' }); + await expect(saveButton).toBeEnabled({ timeout: 5000 }); + await saveButton.click(); await this.waitForToast('Action Updated Successfully'); } @@ -38,46 +49,75 @@ export class Action { await this.createActionButton.click(); await expect(this.createModal).toBeVisible(); await expect(createButton).toBeDisabled(); - await this.actionFormName.fill(this.actionName); - await this.actionFormDescription.fill(this.actionDescription); - await this.actionFormPath.waitFor({ state: 'attached' }); - await setFileInputByFilepath(this.page, this.actionFormPath, this.actionPath, createButton); + await this.page.getByLabel('name').fill(this.actionName); + await this.page.getByLabel('description').fill(this.actionDescription); + const fileInput = this.page.locator('input[name="file"]'); + await fileInput.waitFor({ state: 'attached' }); + await setFileInputByFilepath(this.page, fileInput, this.actionPath, createButton); await expect(createButton).toBeEnabled(); await createButton.click(); await this.waitForToast('Action Created Successfully'); - await this.page.getByRole('button', { name: this.actionName }); - await expect(this.actionDefinitionButton).toBeVisible(); + // Verify action appears in sidebar + await expect(this.page.getByRole('button', { name: this.actionName })).toBeVisible(); return this.actionName; } async inspectAction(): Promise { - await expect(this.actionDefinitionButton).toBeVisible(); - await this.actionDefinitionButton.click(); - await expect( - this.page.locator(`.action-definition-runs-container:has-text("${this.actionDescription}")`), - ).toBeVisible(); + // Click action in sidebar to open detail view + await this.page.getByRole('button', { name: this.actionName }).click(); + // Verify action detail view shows name and description + await expect(this.page.getByRole('heading', { name: this.actionName })).toBeVisible(); + await expect(this.page.getByText(this.actionDescription)).toBeVisible(); + // Verify tabs are present + await expect(this.page.getByRole('tab', { name: /Runs/ })).toBeVisible(); + await expect(this.page.getByRole('tab', { name: 'Configure' })).toBeVisible(); + await expect(this.page.getByRole('tab', { name: 'Code' })).toBeVisible(); } async runAction(): Promise { - await this.actionDefinitionButton.getByRole('button', { name: 'Run' }).click(); - await expect(this.runModal).toBeVisible(); - // Provide the aerie repository path to the action found in `actionPath` so that it can - // successfully query the api - await this.runModal.locator(".parameter-base-string:has-text('repository') input").fill('repos/NASA-AMMOS/plandev'); - await this.runModal.getByRole('button', { name: 'Run' }).click(); - await this.page.waitForURL(`/workspaces/${this.workspaceId}/actions/runs/**`); - await this.page.getByLabel('Complete'); + // Click "Run Action" button in the detail view header + await this.page.getByRole('button', { name: 'Run Action' }).click(); + // Wait for the run modal to appear + const runModal = this.page.locator('#modal-container'); + await expect(runModal).toBeVisible(); + // Click the Run button in the modal footer + await runModal.getByRole('button', { exact: true, name: 'Run' }).click(); + // Verify we navigated to the run detail view + await expect(this.page.getByRole('heading', { name: /Run #\d+/ })).toBeVisible({ timeout: 15000 }); + // Wait for a terminal status (Complete or Failed) in the main content area + const mainContent = this.page.getByRole('main'); + await expect(mainContent.getByLabel('Complete').or(mainContent.getByLabel('Failed'))).toBeVisible({ + timeout: 30000, + }); + } + + async selectActionInSidebar(): Promise { + // Click the action in the sidebar list (scoped to complementary to avoid matching other elements) + await this.page.getByRole('complementary').getByRole('button', { name: this.actionName }).click(); + await expect(this.page.getByRole('heading', { name: this.actionName })).toBeVisible(); + } + + async switchToActionsTab(): Promise { + await this.actionsSidebarTab.click(); + await expect(this.page.getByText('Workspace Actions')).toBeVisible(); + } + + async unarchiveAction(): Promise { + // Navigate to Configure tab and click Unarchive Action + await this.page.getByRole('tab', { name: 'Configure' }).click(); + await this.page.getByRole('button', { name: 'Unarchive Action' }).click(); + // Confirm the unarchive modal + const confirmModal = this.page.locator('#modal-container'); + await expect(confirmModal).toBeVisible(); + await confirmModal.getByRole('button', { name: 'Unarchive' }).click(); + // Verify Run Action button is enabled again + await expect(this.page.getByRole('button', { name: 'Run Action' })).toBeEnabled(); } updatePage(page: Page) { - this.actionDefinitionButton = page.getByRole('button', { name: this.actionName }); - this.actionFormDescription = page.getByLabel('description'); - this.actionFormPath = page.locator('input[name="file"]'); - this.actionFormName = page.getByLabel('name'); + this.actionsSidebarTab = page.getByLabel('Actions', { exact: true }); this.createActionButton = page.getByRole('button', { name: 'New Action' }); - this.createModal = page.locator(`.modal:has-text("New Action")`); - this.createModalDeleteButton = this.createModal.getByRole('button', { name: 'Delete' }); - this.runModal = page.locator(`.modal:has-text("Run Action")`); + this.createModal = page.locator('.modal:has-text("New Action")'); this.page = page; } diff --git a/e2e-tests/tests/actions.test.ts b/e2e-tests/tests/actions.test.ts index 76be459d67..2121e05aa7 100644 --- a/e2e-tests/tests/actions.test.ts +++ b/e2e-tests/tests/actions.test.ts @@ -46,14 +46,8 @@ test.afterAll(async () => { }); test.describe.serial('Actions', () => { - test('Navigate to workspace actions from sidebar', async () => { - const newPagePromise = setup.context.waitForEvent('page'); - await setup.page.getByRole('complementary').getByRole('button', { name: 'Actions' }).click(); - const newTab = await newPagePromise; - await newTab.waitForLoadState(); - await newTab.getByText('Loading...').first().waitFor({ state: 'hidden' }); - await newTab.waitForURL(`/workspaces/${workspaceId}/actions`); - await action.updatePage(newTab); + test('Navigate to workspace actions tab', async () => { + await action.switchToActionsTab(); }); test('Create an action', async () => { @@ -71,4 +65,14 @@ test.describe.serial('Actions', () => { test('Run an action', async () => { await action.runAction(); }); + + test('Archive an action prevents running', async () => { + // Go back to the action detail view by clicking action name in sidebar + await action.selectActionInSidebar(); + await action.archiveAction(); + }); + + test('Unarchive an action allows running again', async () => { + await action.unarchiveAction(); + }); }); diff --git a/src/components/console/views/ConsoleLog.svelte b/src/components/console/views/ConsoleLog.svelte index 91be20c4d2..9e7320a4c3 100644 --- a/src/components/console/views/ConsoleLog.svelte +++ b/src/components/console/views/ConsoleLog.svelte @@ -3,12 +3,9 @@ @@ -111,7 +90,7 @@ expandable ? 'cursor-pointer hover:bg-neutral-200/50' : '', )} > -
+
{#if expandable} {#if open} @@ -149,81 +128,25 @@ {/if}
- {#if log.message} - {@const activityIds = getActivityIdsFromError(log)} -
- {#if log.type === ErrorTypes.WORKSPACE_LINT_ERROR && typeof log.data?.line === 'number' && log.data?.filePath} - {@const location = `${log.data.filePath}:${log.data.line}:${log.data.column ?? 0}`} - {@const messagePrefix = `${location} - `} - {@const messageBody = log.message.startsWith(messagePrefix) - ? log.message.slice(messagePrefix.length) - : log.message} - - {messageBody} - {:else if log.type === ErrorTypes.WORKSPACE_ACTION_RUN && log.data?.actionRunId && log.data?.actionName} - - {#if log.data.status === 'failed'} - failed - {:else} - {log.data.status} - {/if} - {:else if activityIds.length === 1 && log.message} - {@const activityId = activityIds[0]} - {@const activityMatch = log.message.match(/^(.*?)(Activity Directive \d+)(.*)$/)} - {#if activityMatch} - {activityMatch[1]} - - {activityMatch[3]} - {:else} - {log.message} - - {/if} - {:else if activityIds.length > 1 && log.message} - {log.message} - {#each activityIds as activityId, i} - {#if i > 0},{/if} - - {/each} - {:else} - {log.message} - {/if} - {#if isLogMessage(log) && typeof log.duration === 'number'} -
({formatMS(log.duration)})
- {/if} -
- {/if} +
+ {#if !log.message.trim() && log.data && !(expandable && open)} +
{safeStringify(log.data)}
+ {:else} +
{log.message ?? ''}
+ {/if} + + {#if isLogMessage(log) && typeof log.duration === 'number'} +
({formatMS(log.duration)})
+ {/if} +
{#if expandable && open}
- {#if log.timestamp} + {#if log.timestamp && showLongTimestamp}
Timestamp: {formatLogLongTimestamp(log.timestamp)}
diff --git a/src/components/console/views/ConsoleLogs.svelte b/src/components/console/views/ConsoleLogs.svelte index 25ec45ba80..1efd4c75f8 100644 --- a/src/components/console/views/ConsoleLogs.svelte +++ b/src/components/console/views/ConsoleLogs.svelte @@ -2,7 +2,7 @@ + +{#if activityIds.length === 1 && log.message} + {@const activityId = activityIds[0]} + {@const activityMatch = log.message.match(/^(.*?)(Activity Directive \d+)(.*)$/)} + {#if activityMatch} + {activityMatch[1]} + + {activityMatch[3]} + {:else} + {log.message} + + {/if} +{:else if activityIds.length > 1 && log.message} + {log.message} + {#each activityIds as activityId, i} + {#if i > 0},{/if} + + {/each} +{:else} + {log.message ?? ''} +{/if} diff --git a/src/components/console/views/WorkspaceLogMessage.svelte b/src/components/console/views/WorkspaceLogMessage.svelte new file mode 100644 index 0000000000..ec49d19651 --- /dev/null +++ b/src/components/console/views/WorkspaceLogMessage.svelte @@ -0,0 +1,52 @@ + + + + +{#if log.type === ErrorTypes.WORKSPACE_LINT_ERROR && typeof log.data?.line === 'number' && log.data?.filePath} + {@const location = `${log.data.filePath}:${log.data.line}:${log.data.column ?? 0}`} + {@const messagePrefix = `${location} - `} + {@const messageBody = log.message?.startsWith(messagePrefix) ? log.message.slice(messagePrefix.length) : log.message} + + {messageBody} +{:else if log.type === ErrorTypes.WORKSPACE_ACTION_RUN && log.data?.actionRunId && log.data?.actionName} + + {#if log.data.status === 'failed'} + failed + {:else} + {log.data.status} + {/if} +{:else} + {log.message ?? ''} +{/if} diff --git a/src/components/modals/RunActionModal.svelte b/src/components/modals/RunActionModal.svelte index a854460d88..09c3037eb2 100644 --- a/src/components/modals/RunActionModal.svelte +++ b/src/components/modals/RunActionModal.svelte @@ -1,13 +1,21 @@ - - Run Action + function onResetFormParameter(event: CustomEvent) { + const { detail: formParameter } = event; + const { [formParameter.name]: _, ...rest } = argumentsMap; + argumentsMap = rest; + } + + function onChangeSettingsParameters(event: CustomEvent) { + const { detail: formParameter } = event; + settingsArgumentsMap = getArguments(settingsArgumentsMap, formParameter); + } + + + {isRerun ? `Re-run: ${actionDefinition.name}` : actionDefinition.name} -
Input parameters to run {actionDefinition.name}
- + {#if versionMismatch} + + + Warning + + {#if initialVersionArchived} + Version {initialRevision} from the original run has been archived. This re-run will use version {effectiveSelectedRevision}. + {:else} + The selected version (v{effectiveSelectedRevision}) differs from the original run's version (v{initialRevision}). + {/if} + + + {/if} + {#if showSettings && Object.keys(settingsParametersMap).length > 0} +
+
+ Input settings for this action run using {actionDefinition.name} version {selectedVersion?.revision} +
+ +
+ {/if} + + {#if !selectedVersion} + + + No runnable versions + + All versions of this action have been archived. Unarchive a version before running. + + + {:else} + {#if Object.keys(parametersMap).length === 0} + No parameters defined for this action + {:else} +
Input parameters for this action run
+ {/if} + + {/if}
+ - diff --git a/src/components/parameters/ParameterInfo.svelte b/src/components/parameters/ParameterInfo.svelte index 26a0e875f6..f8bd5928d1 100644 --- a/src/components/parameters/ParameterInfo.svelte +++ b/src/components/parameters/ParameterInfo.svelte @@ -4,11 +4,12 @@ import { Popover } from '@nasa-jpl/stellar-svelte'; import { Info } from 'lucide-svelte'; import { createEventDispatcher } from 'svelte'; - import type { FormParameter, ValueSource } from '../../types/parameter'; + import type { FormParameter, ParameterType, ValueSource } from '../../types/parameter'; import ValueSourceBadge from './ValueSourceBadge.svelte'; export let formParameter: FormParameter; export let disabled: boolean = false; + export let parameterType: ParameterType = 'activity'; const dispatch = createEventDispatcher<{ reset: FormParameter; @@ -101,7 +102,7 @@ {#if source !== 'none'}
Source
- +
{/if} {#if externalEvent} diff --git a/src/components/parameters/Parameters.svelte b/src/components/parameters/Parameters.svelte index 243b867704..7fed6b7dab 100644 --- a/src/components/parameters/Parameters.svelte +++ b/src/components/parameters/Parameters.svelte @@ -65,7 +65,7 @@ {/if}
{#if !hideInfo} - + {/if}
diff --git a/src/components/parameters/ValueSourceBadge.svelte b/src/components/parameters/ValueSourceBadge.svelte index 87ac8c66f8..f4deaab05d 100644 --- a/src/components/parameters/ValueSourceBadge.svelte +++ b/src/components/parameters/ValueSourceBadge.svelte @@ -36,7 +36,9 @@ $: if (browser) { const presetText = parameterType === 'activity' ? 'Activity Preset' : 'Simulation Template'; const defaultText = - parameterType === 'goal' ? 'Default' : parameterType === 'constraint' ? 'Default' : 'Mission Model'; + parameterType === 'goal' || parameterType === 'constraint' || parameterType === 'action' + ? 'Default' + : 'Mission Model'; showButton = false; switch (source) { case 'user on model': diff --git a/src/components/sequencing/actions/ActionDetailView.svelte b/src/components/sequencing/actions/ActionDetailView.svelte new file mode 100644 index 0000000000..04c2a8d8bc --- /dev/null +++ b/src/components/sequencing/actions/ActionDetailView.svelte @@ -0,0 +1,805 @@ + + + + +{#if actionDefinition} +
+ +
+
+
+

{actionDefinition.name}

+ {#if actionDefinition.archived} + Archived + {/if} +
+ {#if actionDefinition.description} +

{actionDefinition.description}

+ {/if} +
+
+
+ +
+ +
+ +
+ +
+
+ + +
+ + +
+ +
+ Runs ({actionRuns.length}) +
+
+ +
Configure
+
+ +
Code
+
+
+ {#if activeTab === 'runs'} +
+ +
+ {:else if activeTab === 'configure'} +
+
+ +
+
+ +
+
+ {:else if activeTab === 'code'} +
+ + + + + + {#if displayedVersions.length === 0} +
+ No unarchived versions available +
+ {/if} + {#each displayedVersions as version, i} + + {/each} +
+ +
+
+ {#if selectedVersion} + + Author: + {selectedVersion.author ?? 'Unknown'} • Created: {new Date( + selectedVersion.created_at, + ).toLocaleDateString()} + + {/if} + + {#if selectedVersion && (selectedVersion.archived || actionDefinition.versions.filter(v => !v.archived && v !== selectedVersion).length > 0)} + + {/if} +
+ {/if} +
+ + + +
+ {#if actionRuns.length === 0} +
+ No runs for this action yet +
+ {:else} + + {/if} +
+
+ + + +
+
+

Action Metadata

+
+ {#if actionDefinition.owner} + Owner: {actionDefinition.owner} + {/if} + + Updated: {new Date(actionDefinition.updated_at).toLocaleDateString()} + + {#if latestNonArchivedVersion} + + Latest Version: v{latestNonArchivedVersion.revision} + + {/if} +
+ + + + + + + +