diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/ActionExecutionHistoryTab.tsx b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/ActionExecutionHistoryTab.tsx new file mode 100644 index 000000000000..ff5ed1eb4eb3 --- /dev/null +++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/ActionExecutionHistoryTab.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react"; +import styled from "styled-components"; + +import { Text } from "@appsmith/ads"; + +import type { ActionExecutionHistoryEntry } from "PluginActionEditor/store"; + +const Container = styled.div` + display: grid; + grid-template-columns: minmax(240px, 320px) 1fr; + height: 100%; + background: var(--ads-v2-color-bg); +`; + +const HistoryList = styled.div` + border-right: 1px solid var(--ads-v2-color-border); + overflow: auto; +`; + +const HistoryItem = styled.button<{ $isSelected: boolean }>` + width: 100%; + display: grid; + grid-template-columns: 1fr auto; + gap: var(--ads-v2-spaces-2); + padding: var(--ads-v2-spaces-3); + border: 0; + border-bottom: 1px solid var(--ads-v2-color-border); + background: ${({ $isSelected }) => + $isSelected + ? "var(--ads-v2-color-bg-muted)" + : "var(--ads-v2-color-bg)"}; + color: var(--ads-v2-color-fg); + cursor: pointer; + text-align: left; + + &:hover { + background: var(--ads-v2-color-bg-muted); + } +`; + +const Status = styled.span<{ $status: ActionExecutionHistoryEntry["status"] }>` + color: ${({ $status }) => + $status === "SUCCESS" + ? "var(--ads-v2-color-fg-success)" + : $status === "FAILURE" + ? "var(--ads-v2-color-fg-error)" + : "var(--ads-v2-color-fg-warning)"}; + font-size: 12px; + font-weight: 600; +`; + +const DetailPane = styled.div` + overflow: auto; + padding: var(--ads-v2-spaces-4); +`; + +const Metadata = styled.div` + display: flex; + flex-wrap: wrap; + gap: var(--ads-v2-spaces-3); + padding-bottom: var(--ads-v2-spaces-4); + border-bottom: 1px solid var(--ads-v2-color-border); +`; + +const PreviewSection = styled.div` + margin-top: var(--ads-v2-spaces-4); +`; + +const Preview = styled.pre` + margin: var(--ads-v2-spaces-2) 0 0; + padding: var(--ads-v2-spaces-3); + border: 1px solid var(--ads-v2-color-border); + border-radius: var(--ads-v2-border-radius); + background: var(--ads-v2-color-bg-muted); + color: var(--ads-v2-color-fg); + font-size: 12px; + line-height: 18px; + overflow: auto; + white-space: pre-wrap; +`; + +const EmptyState = styled.div` + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: var(--ads-v2-color-fg-muted); +`; + +function formatTime(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date(timestamp)); +} + +interface ActionExecutionHistoryTabProps { + history: ActionExecutionHistoryEntry[]; +} + +export function ActionExecutionHistoryTab({ + history, +}: ActionExecutionHistoryTabProps) { + const [selectedId, setSelectedId] = useState(); + const selectedRun = + history.find((entry) => entry.id === selectedId) || history[0]; + + if (!selectedRun) { + return ( + + Run this query to see execution history. + + ); + } + + return ( + + + {history.map((entry) => ( + setSelectedId(entry.id)} + type="button" + > +
+ {formatTime(entry.createdAt)} + {entry.environmentName || "Environment"} +
+
+ {entry.status} + {entry.duration || "0"}ms +
+
+ ))} +
+ + + Status: {selectedRun.status} + Duration: {selectedRun.duration || "0"}ms + + Environment: {selectedRun.environmentName || "Environment"} + + + + Request preview + {selectedRun.requestPreview || "No request preview"} + + + Response preview + + {selectedRun.responsePreview || "No response preview"} + + + +
+ ); +} diff --git a/app/client/src/PluginActionEditor/store/pluginActionEditorActions.ts b/app/client/src/PluginActionEditor/store/pluginActionEditorActions.ts index 2f57b3822f09..2bcb29a9f918 100644 --- a/app/client/src/PluginActionEditor/store/pluginActionEditorActions.ts +++ b/app/client/src/PluginActionEditor/store/pluginActionEditorActions.ts @@ -1,4 +1,7 @@ -import type { PluginEditorDebuggerState } from "./pluginEditorReducer"; +import type { + ActionExecutionHistoryEntry, + PluginEditorDebuggerState, +} from "./pluginEditorReducer"; import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import { type ReduxAction } from "actions/ReduxActionTypes"; import type { Action } from "entities/Action"; @@ -24,6 +27,13 @@ export const openPluginActionSettings = (payload: boolean) => ({ }, }); +export const addActionExecutionHistoryEntry = ( + payload: ActionExecutionHistoryEntry, +) => ({ + type: ReduxActionTypes.ADD_ACTION_EXECUTION_HISTORY_ENTRY, + payload, +}); + export const changeApi = ( id: string, isSaas: boolean, diff --git a/app/client/src/PluginActionEditor/store/pluginActionEditorSelectors.ts b/app/client/src/PluginActionEditor/store/pluginActionEditorSelectors.ts index dfaa5496e658..591c2302f66e 100644 --- a/app/client/src/PluginActionEditor/store/pluginActionEditorSelectors.ts +++ b/app/client/src/PluginActionEditor/store/pluginActionEditorSelectors.ts @@ -41,6 +41,14 @@ export const getPluginActionConfigSelectedTab = (state: DefaultRootState) => export const getPluginActionDebuggerState = (state: DefaultRootState) => state.ui.pluginActionEditor.debugger; +const getActionExecutionHistoryState = (state: DefaultRootState) => + state.ui.pluginActionEditor.actionExecutionHistory; + +export const getActionExecutionHistory = (id: string) => + createSelector([getActionExecutionHistoryState], (historyMap) => { + return historyMap[id] || []; + }); + export const isPluginActionCreating = (state: DefaultRootState) => state.ui.pluginActionEditor.isCreating; diff --git a/app/client/src/PluginActionEditor/store/pluginEditorReducer.ts b/app/client/src/PluginActionEditor/store/pluginEditorReducer.ts index 12fb30725ec5..3615b90e9530 100644 --- a/app/client/src/PluginActionEditor/store/pluginEditorReducer.ts +++ b/app/client/src/PluginActionEditor/store/pluginEditorReducer.ts @@ -12,12 +12,25 @@ import { omit, set } from "lodash"; import { objectKeys } from "@appsmith/utils"; import type { UpdateActionPropertyActionPayload } from "actions/pluginActionActions"; +const ACTION_EXECUTION_HISTORY_LIMIT = 20; + export interface PluginEditorDebuggerState { open: boolean; responseTabHeight: number; selectedTab?: string; } +export interface ActionExecutionHistoryEntry { + id: string; + actionId: string; + status: "SUCCESS" | "FAILURE" | "CANCELLED"; + duration: string; + environmentName?: string; + requestPreview?: string; + responsePreview?: string; + createdAt: number; +} + export interface PluginActionEditorState { isCreating: boolean; isRunning: Record; @@ -26,6 +39,7 @@ export interface PluginActionEditorState { isDeleting: Record; isDirty: Record; runErrorMessage: Record; + actionExecutionHistory: Record; selectedConfigTab?: string; debugger: PluginEditorDebuggerState; settingsOpen?: boolean; @@ -39,6 +53,7 @@ const initialState: PluginActionEditorState = { isDeleting: {}, isDirty: {}, runErrorMessage: {}, + actionExecutionHistory: {}, debugger: { open: false, responseTabHeight: ActionExecutionResizerHeight, @@ -124,6 +139,22 @@ export const handlers = { ) => { set(state, ["isRunning", action.payload.id], false); }, + [ReduxActionTypes.ADD_ACTION_EXECUTION_HISTORY_ENTRY]: ( + state: PluginActionEditorState, + action: ReduxAction, + ) => { + const { actionId } = action.payload; + const existingHistory = state.actionExecutionHistory[actionId] || []; + + set( + state, + ["actionExecutionHistory", actionId], + [action.payload, ...existingHistory].slice( + 0, + ACTION_EXECUTION_HISTORY_LIMIT, + ), + ); + }, [ReduxActionTypes.RUN_ACTION_SUCCESS]: ( state: PluginActionEditorState, diff --git a/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx b/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx index 80563b6d3d89..fa815aaa0e27 100644 --- a/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx +++ b/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx @@ -21,6 +21,7 @@ import { ApiResponseHeaders } from "PluginActionEditor/components/PluginActionRe import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; import { getErrorCount } from "selectors/debuggerSelectors"; import { + getActionExecutionHistory, getPluginActionDebuggerState, isActionRunning, } from "PluginActionEditor/store"; @@ -37,6 +38,9 @@ import { Response } from "PluginActionEditor/components/PluginActionResponse/com import { StateInspector } from "components/editorComponents/Debugger/StateInspector"; import { useLocation } from "react-router"; import { getIDETypeByUrl } from "ee/entities/IDE/utils"; +import { ActionExecutionHistoryTab } from "PluginActionEditor/components/PluginActionResponse/components/ActionExecutionHistoryTab"; + +const ACTION_EXECUTION_HISTORY_TAB = "ACTION_EXECUTION_HISTORY_TAB"; function usePluginActionResponseTabs() { const { action, actionResponse, datasource, plugin } = @@ -47,6 +51,9 @@ function usePluginActionResponseTabs() { const IDEViewMode = useSelector(getIDEViewMode); const errorCount = useSelector(getErrorCount); const pluginRequireDatasource = doesPluginRequireDatasource(plugin); + const actionExecutionHistory = useSelector( + getActionExecutionHistory(action.id), + ); const showSchema = useShowSchema(plugin.id) && pluginRequireDatasource; @@ -94,6 +101,14 @@ function usePluginActionResponseTabs() { /> ), }, + { + key: ACTION_EXECUTION_HISTORY_TAB, + title: "History", + count: actionExecutionHistory.length, + panelComponent: ( + + ), + }, { key: DEBUGGER_TAB_KEYS.HEADER_TAB, title: createMessage(DEBUGGER_HEADERS), @@ -149,6 +164,15 @@ function usePluginActionResponseTabs() { /> ), }); + + tabs.push({ + key: ACTION_EXECUTION_HISTORY_TAB, + title: "History", + count: actionExecutionHistory.length, + panelComponent: ( + + ), + }); } const location = useLocation(); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 2d08cb91cd83..54a56f5cce6c 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -743,6 +743,7 @@ const ActionExecutionTypes = { RUN_ACTION_REQUEST: "RUN_ACTION_REQUEST", RUN_ACTION_CANCELLED: "RUN_ACTION_CANCELLED", RUN_ACTION_SUCCESS: "RUN_ACTION_SUCCESS", + ADD_ACTION_EXECUTION_HISTORY_ENTRY: "ADD_ACTION_EXECUTION_HISTORY_ENTRY", GENERATE_JS_FUNCTION_SCHEMA_REQUEST: "GENERATE_JS_FUNCTION_SCHEMA_REQUEST", GENERATE_JS_FUNCTION_SCHEMA_CANCELLED: "GENERATE_JS_FUNCTION_SCHEMA_CANCELLED", diff --git a/app/client/src/pages/Editor/QueryEditor/QueryDebuggerTabs.tsx b/app/client/src/pages/Editor/QueryEditor/QueryDebuggerTabs.tsx index 19afb1f4a8ef..08d829b25776 100644 --- a/app/client/src/pages/Editor/QueryEditor/QueryDebuggerTabs.tsx +++ b/app/client/src/pages/Editor/QueryEditor/QueryDebuggerTabs.tsx @@ -26,6 +26,7 @@ import { DatasourceComponentTypes } from "entities/Plugin"; import { fetchDatasourceStructure } from "actions/datasourceActions"; import { DatasourceStructureContext } from "entities/Datasource"; import { + getActionExecutionHistory, getPluginActionDebuggerState, setPluginActionEditorDebuggerState, } from "PluginActionEditor/store"; @@ -34,6 +35,9 @@ import { getIDEViewMode } from "selectors/ideSelectors"; import { EditorViewMode } from "IDE/Interfaces/EditorTypes"; import { IDEBottomView, ViewHideBehaviour } from "IDE"; import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; +import { ActionExecutionHistoryTab } from "PluginActionEditor/components/PluginActionResponse/components/ActionExecutionHistoryTab"; + +const ACTION_EXECUTION_HISTORY_TAB = "ACTION_EXECUTION_HISTORY_TAB"; interface QueryDebuggerTabsProps { actionSource: SourceEntity; @@ -61,6 +65,9 @@ function QueryDebuggerTabs({ const { open, responseTabHeight, selectedTab } = useSelector( getPluginActionDebuggerState, ); + const actionExecutionHistory = useSelector( + getActionExecutionHistory(currentActionConfig?.id || ""), + ); const { responseDisplayFormat } = actionResponseDisplayDataFormats(actionResponse); @@ -212,6 +219,15 @@ function QueryDebuggerTabs({ /> ), }); + + responseTabs.push({ + key: ACTION_EXECUTION_HISTORY_TAB, + title: "History", + count: actionExecutionHistory.length, + panelComponent: ( + + ), + }); } if (showSchema && currentActionConfig && currentActionConfig.datasource) { diff --git a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts index e1db4449d5b8..7a74688aa221 100644 --- a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts @@ -159,6 +159,7 @@ import { import type { JSAction, JSCollection } from "entities/JSCollection"; import { getAllowedActionAnalyticsKeys } from "constants/AppsmithActionConstants/formConfig/ActionAnalyticsConfig"; import { + addActionExecutionHistoryEntry, changeQuery, isActionDirty, isActionSaving, @@ -731,6 +732,26 @@ interface RunActionError { clientDefinedError?: boolean; } +const ACTION_EXECUTION_HISTORY_PREVIEW_LIMIT = 1000; + +function getActionExecutionHistoryPreview(value: unknown) { + if (isNil(value)) return ""; + + let preview = ""; + + try { + preview = isString(value) ? value : JSON.stringify(value, null, 2); + } catch (e) { + preview = String(value); + } + + if (!preview) return String(value); + + if (preview.length <= ACTION_EXECUTION_HISTORY_PREVIEW_LIMIT) return preview; + + return `${preview.slice(0, ACTION_EXECUTION_HISTORY_PREVIEW_LIMIT)}...`; +} + export function* runActionSaga( reduxAction: ReduxAction<{ id: string; @@ -808,6 +829,20 @@ export function* runActionSaga( // When running from the pane, we just want to end the saga if the user has // cancelled the call. No need to log any errors if (e instanceof UserCancelledActionExecutionError) { + yield put( + addActionExecutionHistoryEntry({ + id: `${actionId}-cancelled-${Date.now()}`, + actionId, + status: "CANCELLED", + duration: "0", + environmentName: currentEnvDetails.name, + responsePreview: createMessage( + ACTION_EXECUTION_CANCELLED, + pluginActionNameToDisplay, + ), + createdAt: Date.now(), + }), + ); // cancel action but do not throw any error. yield put({ type: ReduxActionErrorTypes.RUN_ACTION_ERROR, @@ -907,6 +942,23 @@ export function* runActionSaga( transportError || defaultError; + yield put( + addActionExecutionHistoryEntry({ + id: `${actionId}-failure-${Date.now()}`, + actionId, + status: "FAILURE", + duration: payload.duration, + environmentName: currentEnvDetails.name, + requestPreview: getActionExecutionHistoryPreview(payload.request), + responsePreview: getActionExecutionHistoryPreview( + payload.pluginErrorDetails?.downstreamErrorMessage || + error.message || + payload.body, + ), + createdAt: Date.now(), + }), + ); + // In case of debugger, both the current error message // and the readableError needs to be present, // since the readableError may be malformed for certain errors. @@ -998,6 +1050,19 @@ export function* runActionSaga( payload: { [actionId]: payload }, }); + yield put( + addActionExecutionHistoryEntry({ + id: `${actionId}-success-${Date.now()}`, + actionId, + status: "SUCCESS", + duration: payload.duration, + environmentName: currentEnvDetails.name, + requestPreview: getActionExecutionHistoryPreview(payload.request), + responsePreview: getActionExecutionHistoryPreview(payload.body), + createdAt: Date.now(), + }), + ); + if (payload.isExecutionSuccess) { AppsmithConsole.info({ logType: LOG_TYPE.ACTION_EXECUTION_SUCCESS,