diff --git a/ui.frontend/src/components/ExecutionAbortButton.tsx b/ui.frontend/src/components/ExecutionAbortButton.tsx index 4eeaad9c..7dd630bc 100644 --- a/ui.frontend/src/components/ExecutionAbortButton.tsx +++ b/ui.frontend/src/components/ExecutionAbortButton.tsx @@ -1,80 +1,70 @@ import React, { useState } from 'react'; -import { - Button, - ButtonGroup, - Content, - Dialog, - DialogTrigger, - Divider, - Heading, - Text -} from '@adobe/react-spectrum'; -import { toastRequest } from '../utils/api'; +import { Button, Text } from '@adobe/react-spectrum'; import Cancel from '@spectrum-icons/workflow/Cancel'; -import Checkmark from '@spectrum-icons/workflow/Checkmark'; -import {QueueOutput} from "../utils/api.types.ts"; +import { ToastQueue } from '@react-spectrum/toast'; +import { apiRequest } from '../utils/api'; +import {Execution, ExecutionStatus, isExecutionPending, QueueOutput} from '../utils/api.types'; -type ExecutionAbortButtonProps = { - selectedKeys: string[]; - onAbort?: () => void; -}; - -const ExecutionAbortButton: React.FC = ({ selectedKeys, onAbort }) => { - const [abortDialogOpen, setAbortDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); +const executionPollInterval = 1000; +const toastTimeout = 3000; - const handleConfirm = async () => { - setIsLoading(true); - const ids = Array.from(selectedKeys); +interface ExecutionAbortButtonProps { + execution: Execution | null; + onComplete: (execution: Execution | null) => void; +} - const params = new URLSearchParams(); - ids.forEach((id) => params.append('jobId', id)); +const ExecutionAbortButton: React.FC = ({ execution, onComplete }) => { + const [isAborting, setIsAborting] = useState(false); + const onAbort = async () => { + if (!execution?.id) { + console.warn('Code execution cannot be aborted as it is not running!'); + return; + } + setIsAborting(true); try { - await toastRequest({ - method: 'DELETE', - url: `/apps/acm/api/queue-code.json?${params.toString()}`, - operation: 'Abort executions', + await apiRequest({ + operation: 'Code execution aborting', + url: `/apps/acm/api/queue-code.json?jobId=${execution.id}`, + method: 'delete', }); - if (onAbort) onAbort(); + + let queuedExecution: Execution | null = null; + while (queuedExecution === null || isExecutionPending(queuedExecution.status)) { + const response = await apiRequest({ + operation: 'Code execution state', + url: `/apps/acm/api/queue-code.json?jobId=${execution.id}`, + method: 'get', + }); + queuedExecution = response.data.data.executions[0]!; + onComplete(queuedExecution); + await new Promise((resolve) => setTimeout(resolve, executionPollInterval)); + } + if (queuedExecution.status === ExecutionStatus.ABORTED) { + ToastQueue.positive('Code execution aborted successfully!', { + timeout: toastTimeout, + }); + } else { + console.warn('Code execution aborting failed!'); + ToastQueue.negative('Code execution aborting failed!', { + timeout: toastTimeout, + }); + } } catch (error) { - console.error('Abort executions error:', error); + console.error('Code execution aborting error:', error); + ToastQueue.negative('Code execution aborting failed!', { + timeout: toastTimeout, + }); } finally { - setIsLoading(false); - setAbortDialogOpen(false); + setIsAborting(false); } }; - const renderAbortDialog = () => ( - <> - - Confirmation - - - - Are you sure you want to abort the selected executions? This action cannot be undone. - - - - - - - ); - return ( - - - {renderAbortDialog()} - + ); }; diff --git a/ui.frontend/src/components/ExecutionCopyOutputButton.tsx b/ui.frontend/src/components/ExecutionCopyOutputButton.tsx new file mode 100644 index 00000000..001248d8 --- /dev/null +++ b/ui.frontend/src/components/ExecutionCopyOutputButton.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Button, ButtonGroup, Text } from '@adobe/react-spectrum'; +import { ToastQueue } from '@react-spectrum/toast'; +import Copy from '@spectrum-icons/workflow/Copy'; + +const toastTimeout = 3000; + +interface ExecutionCopyOutputButtonProps { + output: string; +} + +const ExecutionCopyOutputButton: React.FC = ({ output }) => { + const onCopyExecutionOutput = () => { + if (output) { + navigator.clipboard + .writeText(output) + .then(() => { + ToastQueue.info('Execution output copied to clipboard!', { + timeout: toastTimeout, + }); + }) + .catch(() => { + ToastQueue.negative('Failed to copy execution output!', { + timeout: toastTimeout, + }); + }); + } else { + ToastQueue.negative('No execution output to copy!', { + timeout: toastTimeout, + }); + } + }; + + return ( + + ); +}; + +export default ExecutionCopyOutputButton; \ No newline at end of file diff --git a/ui.frontend/src/components/ExecutionProgressBar.tsx b/ui.frontend/src/components/ExecutionProgressBar.tsx index bd0837c6..53e31e8f 100644 --- a/ui.frontend/src/components/ExecutionProgressBar.tsx +++ b/ui.frontend/src/components/ExecutionProgressBar.tsx @@ -2,10 +2,11 @@ import { Meter, ProgressBar } from '@adobe/react-spectrum'; import React from 'react'; import { Execution, isExecutionPending } from '../utils/api.types.ts'; import { Strings } from '../utils/strings.ts'; +import {useFormatter} from "../utils/hooks/formatter.ts"; interface ExecutionProgressBarProps { execution: Execution | null; - active: boolean; + active?: boolean; } const ExecutionProgressBar: React.FC = ({ execution, active }) => { @@ -23,13 +24,15 @@ const ExecutionProgressBar: React.FC = ({ execution, } }; + const formatter = useFormatter(); + return ( <> {execution ? ( active || isExecutionPending(execution.status) ? ( ) : ( - + ) ) : ( diff --git a/ui.frontend/src/components/ExecutionStatusBadge.tsx b/ui.frontend/src/components/ExecutionStatusBadge.tsx index 6461ad88..ca194395 100644 --- a/ui.frontend/src/components/ExecutionStatusBadge.tsx +++ b/ui.frontend/src/components/ExecutionStatusBadge.tsx @@ -1,4 +1,4 @@ -import {Badge, ProgressCircle, Text} from '@adobe/react-spectrum'; +import { Badge, ProgressCircle, SpectrumBadgeProps, Text } from '@adobe/react-spectrum'; import Alert from '@spectrum-icons/workflow/Alert'; import Cancel from '@spectrum-icons/workflow/Cancel'; import Checkmark from '@spectrum-icons/workflow/Checkmark'; @@ -7,9 +7,9 @@ import Pause from '@spectrum-icons/workflow/Pause'; import React from 'react'; import { ExecutionStatus } from '../utils/api.types'; -interface ExecutionStatusProps { +type ExecutionStatusProps = { value: ExecutionStatus; -} +} & Partial; const getVariant = (status: ExecutionStatus): 'positive' | 'negative' | 'neutral' | 'info' | 'yellow' => { switch (status) { @@ -52,12 +52,12 @@ const getIcon = (status: ExecutionStatus) => { } }; -const ExecutionStatusBadge: React.FC = ({ value }) => { +const ExecutionStatusBadge: React.FC = ({ value, ...props }) => { const variant = getVariant(value); const icon = getIcon(value); return ( - + {value.toLowerCase()} {icon} diff --git a/ui.frontend/src/components/ExecutionsAbortButton.tsx b/ui.frontend/src/components/ExecutionsAbortButton.tsx new file mode 100644 index 00000000..563fb652 --- /dev/null +++ b/ui.frontend/src/components/ExecutionsAbortButton.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { + Button, + ButtonGroup, + Content, + Dialog, + DialogTrigger, + Divider, + Heading, + Text +} from '@adobe/react-spectrum'; +import { toastRequest } from '../utils/api'; +import Cancel from '@spectrum-icons/workflow/Cancel'; +import Checkmark from '@spectrum-icons/workflow/Checkmark'; +import {QueueOutput} from "../utils/api.types.ts"; + +type ExecutionsAbortButtonProps = { + selectedKeys: string[]; + onAbort?: () => void; +}; + +const ExecutionsAbortButton: React.FC = ({ selectedKeys, onAbort }) => { + const [abortDialogOpen, setAbortDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleConfirm = async () => { + setIsLoading(true); + const ids = Array.from(selectedKeys); + + const params = new URLSearchParams(); + ids.forEach((id) => params.append('jobId', id)); + + try { + await toastRequest({ + method: 'DELETE', + url: `/apps/acm/api/queue-code.json?${params.toString()}`, + operation: 'Abort executions', + }); + if (onAbort) onAbort(); + } catch (error) { + console.error('Abort executions error:', error); + } finally { + setIsLoading(false); + setAbortDialogOpen(false); + } + }; + + const renderAbortDialog = () => ( + <> + + Confirmation + + + + Are you sure you want to abort the selected executions? This action cannot be undone. + + + + + + + ); + + return ( + + + {renderAbortDialog()} + + ); +}; + +export default ExecutionsAbortButton; \ No newline at end of file diff --git a/ui.frontend/src/components/ScriptExecutor.tsx b/ui.frontend/src/components/ScriptExecutor.tsx index b13a5172..39fd0431 100644 --- a/ui.frontend/src/components/ScriptExecutor.tsx +++ b/ui.frontend/src/components/ScriptExecutor.tsx @@ -38,7 +38,7 @@ import Replay from "@spectrum-icons/workflow/Replay"; import Checkmark from "@spectrum-icons/workflow/Checkmark"; import Cancel from "@spectrum-icons/workflow/Cancel"; import Settings from "@spectrum-icons/workflow/Settings"; -import ExecutionAbortButton from './ExecutionAbortButton'; +import ExecutionsAbortButton from './ExecutionsAbortButton.tsx'; import Code from "@spectrum-icons/workflow/Code"; const ScriptExecutor = () => { @@ -70,7 +70,7 @@ const ScriptExecutor = () => { - + - - - - setSelectedTab('output')} isCompiling={isCompiling} syntaxError={syntaxError} compilationError={compilationError} /> - - - + + setSelectedTab(key as SelectedTab)}> + + + + Code + + + + Output + + + + + + + + + + + + + setSelectedTab('output')} isCompiling={isCompiling} syntaxError={syntaxError} compilationError={compilationError} /> + + + + + - - - - - - - - - - - - setAutoscroll((prev) => !prev)}> - Autoscroll - - - - - - - - - {(close) => ( - - Code execution - - -

- Output is printed live. -

-

- Abort if the execution: -

    -
  • - is taking too long -
  • -
  • - is stuck in an infinite loop -
  • -
  • - makes the instance unresponsive -
  • -
-

-

- Be aware that aborting execution may leave data in an inconsistent state. -

-
- - - -
- )} -
+
+ + + + + + + + + setAutoscroll((prev) => !prev)}> + Autoscroll + + + + + + + + + {(close) => ( + + Code execution + + +

+ Output is printed live. +

+

+ Abort if the execution: +

    +
  • + is taking too long +
  • +
  • + is stuck in an infinite loop +
  • +
  • + makes the instance unresponsive +
  • +
+

+

+ Be aware that aborting execution may leave data in an inconsistent state. +

+
+ + + +
+ )} +
+
+
- -
- - - -
+ + + + ); }; -export default ConsolePage; +export default ConsolePage; \ No newline at end of file diff --git a/ui.frontend/src/pages/DashboardPage.tsx b/ui.frontend/src/pages/DashboardPage.tsx index 69ad989c..5b9e1c40 100644 --- a/ui.frontend/src/pages/DashboardPage.tsx +++ b/ui.frontend/src/pages/DashboardPage.tsx @@ -9,7 +9,7 @@ import Star from '@spectrum-icons/workflow/Star'; const DashboardPage = () => { return ( { - + - + - New Approach - Experience a different way of using Groovy scripts. ACM ensures the instance is healthy before scripts decide when to run: once, periodically, or at an exact date and time. Execute scripts in parallel or sequentially, offering a complete change in paradigm. Unlike traditional methods, ACM allows scripts to run at specific moments offering unmatched flexibility and control. + All-in-one + ACM may be a good alternative to tools like APM, AECU, AEM Groovy Console, and AC Tool. Groovy language is ideal to manage all things in content including permissions. In tools like APM/AC Tool, there is a need to learn custom YAML syntax or languages/grammars. In ACM, you only need Groovy, which almost every Java developer knows! Enjoy a single, painless tool setup in AEM projects with no hooks and POM updates. - + - + - All-in-one - ACM may be a good alternative to tools like APM, AECU, AEM Groovy Console, and AC Tool. Groovy language is ideal to manage all things in content including permissions. In tools like APM/AC Tool, there is a need to learn custom YAML syntax or languages/grammars. In ACM, you only need Groovy, which almost every Java developer knows! Enjoy a single, painless tool setup in AEM projects with no hooks and POM updates. + New Approach + Experience a different way of using Groovy scripts. ACM ensures the instance is healthy before scripts decide when to run: once, periodically, or at an exact date and time. Execute scripts in parallel or sequentially, offering a complete change in paradigm. Unlike traditional methods, ACM allows scripts to run at specific moments offering unmatched flexibility and control. diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index 22150aa7..ab16730f 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -1,4 +1,4 @@ -import { Button, ButtonGroup, Content, Flex, IllustratedMessage, Item, LabeledValue, TabList, TabPanels, Tabs, Text, View, ProgressBar } from '@adobe/react-spectrum'; +import { Button, ButtonGroup, Content, Flex, IllustratedMessage, Item, LabeledValue, ProgressBar, Switch, TabList, TabPanels, Tabs, Text, View } from '@adobe/react-spectrum'; import { Field } from '@react-spectrum/label'; import { ToastQueue } from '@react-spectrum/toast'; import NotFound from '@spectrum-icons/illustrations/NotFound'; @@ -6,183 +6,182 @@ import Copy from '@spectrum-icons/workflow/Copy'; import FileCode from '@spectrum-icons/workflow/FileCode'; import History from '@spectrum-icons/workflow/History'; import Print from '@spectrum-icons/workflow/Print'; -import React, { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import ExecutableValue from '../components/ExecutableValue.tsx'; -import ExecutionStatusBadge from '../components/ExecutionStatusBadge.tsx'; -import ImmersiveEditor from '../components/ImmersiveEditor.tsx'; +import { AppContext } from '../AppContext.tsx'; +import ExecutableValue from '../components/ExecutableValue'; +import ExecutionStatusBadge from '../components/ExecutionStatusBadge'; +import ImmersiveEditor from '../components/ImmersiveEditor'; import { toastRequest } from '../utils/api'; -import { Execution, ExecutionOutput } from '../utils/api.types'; +import {Execution, ExecutionOutput, isExecutionPending} from '../utils/api.types'; import { useFormatter } from '../utils/hooks/formatter'; import { useNavigationTab } from '../utils/hooks/navigation'; +import ExecutionProgressBar from "../components/ExecutionProgressBar"; +import ExecutionCopyOutputButton from "../components/ExecutionCopyOutputButton"; +import ExecutionAbortButton from "../components/ExecutionAbortButton"; const toastTimeout = 3000; const ExecutionView = () => { - const [execution, setExecution] = useState(null); - const [loading, setLoading] = useState(true); - const executionId = decodeURIComponent(useParams<{ executionId: string }>().executionId as string); - const formatter = useFormatter(); + const [execution, setExecution] = useState(null); + const [autoscrollOutput, setAutoscrollOutput] = useState(true); + const executionId = decodeURIComponent(useParams<{ executionId: string }>().executionId as string); + const formatter = useFormatter(); + const appState = useContext(AppContext); + const executionInQueue = !!appState?.queuedExecutions.find((execution) => execution.id === executionId); + const [loading, setLoading] = useState(executionInQueue); - useEffect(() => { - const fetchExecution = async () => { - setLoading(true); - try { - const response = await toastRequest({ - method: 'GET', - url: `/apps/acm/api/execution.json?id=${executionId}`, - operation: `Execution loading`, - positive: false, - }); - setExecution(response.data.data.list[0]); - } catch (error) { - console.error(`Execution cannot be loaded '${executionId}':`, error); - } finally { - setLoading(false); - } - }; - fetchExecution(); - }, [executionId]); + useEffect(() => { + const fetchExecution = async () => { + try { + const response = await toastRequest({ + method: 'GET', + url: `/apps/acm/api/execution.json?id=${executionId}`, + operation: `Execution loading`, + positive: false, + }); - const [selectedTab, handleTabChange] = useNavigationTab(executionId ? `/executions/view/${encodeURIComponent(executionId)}` : null, 'details'); + setExecution(response.data.data.list[0]); + } catch (error) { + console.error(`Execution cannot be loaded '${executionId}':`, error); + } finally { + setLoading(false); + } + }; + fetchExecution(); - if (loading) { - return ( - - - - ); - } + if (executionInQueue) { + const intervalId = setInterval(fetchExecution, 500); - if (!execution) { - return ( - - - - Execution not found - - - ); + return () => clearInterval(intervalId); } + }, [executionInQueue, executionId]); - const executionOutput = ((execution.output ?? '') + '\n' + (execution.error ?? '')).trim(); + const [selectedTab, handleTabChange] = useNavigationTab(executionId ? `/executions/view/${encodeURIComponent(executionId)}` : null, 'details'); - const onCopyExecutionOutput = () => { - if (executionOutput) { - navigator.clipboard - .writeText(executionOutput) - .then(() => { - ToastQueue.info('Execution output copied to clipboard!', { - timeout: toastTimeout, - }); - }) - .catch(() => { - ToastQueue.negative('Failed to copy execution output!', { - timeout: toastTimeout, - }); - }); - } else { - ToastQueue.negative('No execution output to copy!', { - timeout: toastTimeout, - }); - } - }; - - const onCopyExecutableCode = () => { - navigator.clipboard - .writeText(execution.executable.content) - .then(() => { - ToastQueue.info('Execution code copied to clipboard!', { - timeout: toastTimeout, - }); - }) - .catch(() => { - ToastQueue.negative('Failed to copy execution code!', { - timeout: toastTimeout, - }); - }); - }; + if (loading) { + return ( + + + + ); + } + if (!execution) { return ( - - - - - - Execution - - - - Code - - - - Output - - - - - - - - - -
- -
-
-
-
- - -
- -
-
-
- - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - -
-
-
+ + + + Execution not found + + ); + } + + const executionOutput = ((execution.output ?? '') + '\n' + (execution.error ?? '')).trim(); + + const onCopyExecutableCode = () => { + navigator.clipboard + .writeText(execution.executable.content) + .then(() => { + ToastQueue.info('Execution code copied to clipboard!', { + timeout: toastTimeout, + }); + }) + .catch(() => { + ToastQueue.negative('Failed to copy execution code!', { + timeout: toastTimeout, + }); + }); + }; + + return ( + + + + + + Execution + + + + Code + + + + Output + + + + + + + + + +
+ +
+
+
+
+ + +
+ +
+
+
+ + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + setAutoscrollOutput((prev) => !prev)}> + Autoscroll + + + + + + +   + + + + + +
+
+
+ ); }; -export default ExecutionView; \ No newline at end of file +export default ExecutionView; diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index 6e12ecf3..90695509 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -24,15 +24,15 @@ export enum ExecutionStatus { SUCCEEDED = 'SUCCEEDED', } -export function isExecutionNegative(status: ExecutionStatus | null) { +export function isExecutionNegative(status: ExecutionStatus | null | undefined) { return status === ExecutionStatus.FAILED || status === ExecutionStatus.ABORTED; } -export function isExecutionPending(status: ExecutionStatus | null) { +export function isExecutionPending(status: ExecutionStatus | null | undefined) { return status === ExecutionStatus.QUEUED || status === ExecutionStatus.ACTIVE; } -export function isExecutionCompleted(status: ExecutionStatus | null) { +export function isExecutionCompleted(status: ExecutionStatus | null | undefined) { return status === ExecutionStatus.FAILED || status === ExecutionStatus.SUCCEEDED; } diff --git a/ui.frontend/src/utils/hooks/formatter.ts b/ui.frontend/src/utils/hooks/formatter.ts index 5d06cea0..4c5063da 100644 --- a/ui.frontend/src/utils/hooks/formatter.ts +++ b/ui.frontend/src/utils/hooks/formatter.ts @@ -51,6 +51,19 @@ class Formatter { return result; } + public durationShort(milliseconds: number): string { + if (milliseconds < 1000) { + return `${milliseconds} ms`; + } + const duration = intervalToDuration({ start: 0, end: milliseconds }); + const parts = []; + if (duration.days) parts.push(`${duration.days}d`); + if (duration.hours) parts.push(`${duration.hours}h`); + if (duration.minutes) parts.push(`${duration.minutes}m`); + if (duration.seconds) parts.push(`${duration.seconds}s`); + return parts.join(' '); + } + public durationExplained(milliseconds: number): string { const duration = intervalToDuration({ start: 0, end: milliseconds }); let result = `${milliseconds} ms`;