diff --git a/.changeset/plenty-bikes-drop.md b/.changeset/plenty-bikes-drop.md new file mode 100644 index 0000000000..eb058e7079 --- /dev/null +++ b/.changeset/plenty-bikes-drop.md @@ -0,0 +1,6 @@ +--- +"@medusajs/ui": patch +"@medusajs/medusa": patch +--- + +fix(medusa,ui) Export param types for workflow endpoints. Add support for JSON to CodeBlock component. diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index d82741aea5..7337b56e67 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -25,7 +25,7 @@ "@tanstack/react-query": "4.22.0", "@tanstack/react-table": "8.10.7", "@tanstack/react-virtual": "^3.0.4", - "@uiw/react-json-view": "2.0.0-alpha.10", + "@uiw/react-json-view": "^2.0.0-alpha.17", "cmdk": "^0.2.0", "date-fns": "^3.2.0", "framer-motion": "^11.0.3", diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index f1d4210ee7..70a5adfe85 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -17,9 +17,6 @@ "is": "is", "select": "Select", "selected": "Selected", - "extensions": "Extensions", - "settings": "Settings", - "general": "General", "details": "Details", "enabled": "Enabled", "disabled": "Disabled", @@ -41,7 +38,14 @@ "noRecordsMessage": "There are no records to show", "unsavedChangesTitle": "Are you sure you want to leave this page?", "unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page.", - "includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved." + "includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved.", + "timeline": "Timeline" + }, + "nav": { + "general": "General", + "developer": "Developer", + "extensions": "Extensions", + "settings": "Settings" }, "actions": { "create": "Create", @@ -497,6 +501,45 @@ "tokenExpiresIn": "Token expires in <0>{{time}} minutes", "successfulRequest": "We have sent you an email with instructions on how to reset your password. If you don't receive an email, please check your spam folder or try again." }, + "executions": { + "domain": "Executions", + "transactionIdLabel": "Transaction ID", + "workflowIdLabel": "Workflow ID", + "progressLabel": "Progress", + "stepsCompletedLabel_one": "{{completed}} of {{count}} step", + "stepsCompletedLabel_other": "{{completed}} of {{count}} steps", + "history": { + "sectionTitle": "History", + "runningState": "Running...", + "awaitingState": "Awaiting", + "failedState": "Failed", + "definitionLabel": "Definition", + "outputLabel": "Output", + "compensateInputLabel": "Compensate input", + "revertedLabel": "Reverted", + "errorLabel": "Error" + }, + "state": { + "done": "Done", + "failed": "Failed", + "reverted": "Reverted", + "invoking": "Invoking", + "compensating": "Compensating", + "notStarted": "Not started" + }, + "transaction": { + "state": { + "waitingToCompensate": "Waiting to compensate" + } + }, + "step": { + "state": { + "skipped": "Skipped", + "dormant": "Dormant", + "timeout": "Timeout" + } + } + }, "errors": { "serverError": "Server error - Try again later.", "invalidCredentials": "Wrong email or password" diff --git a/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx b/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx index 18b5ffaa1c..02c2f6988d 100644 --- a/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx +++ b/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx @@ -1,9 +1,4 @@ -import { - ArrowsPointingOut, - CheckCircleMiniSolid, - SquareTwoStackMini, - XMarkMini, -} from "@medusajs/icons" +import { ArrowsPointingOut, XMarkMini } from "@medusajs/icons" import { Badge, Container, @@ -14,20 +9,27 @@ import { } from "@medusajs/ui" import Primitive from "@uiw/react-json-view" import { CSSProperties, Suspense } from "react" +import { useTranslation } from "react-i18next" -type JsonViewProps = { +type JsonViewSectionProps = { data: object root?: string + title?: string } // TODO: Fix the positioning of the copy btn -export const JsonViewSection = ({ data, root }: JsonViewProps) => { +export const JsonViewSection = ({ + data, + root, + title = "JSON", +}: JsonViewSectionProps) => { + const { t } = useTranslation() const numberOfKeys = Object.keys(data).length return (
- JSON + {title} {numberOfKeys} keys
@@ -40,10 +42,10 @@ export const JsonViewSection = ({ data, root }: JsonViewProps) => { - -
+ +
- JSON + {title} {numberOfKeys} keys
@@ -68,52 +70,53 @@ export const JsonViewSection = ({ data, root }: JsonViewProps) => { style={ { "--w-rjv-font-family": "Roboto Mono, monospace", - "--w-rjv-line-color": "#2E3035", - "--w-rjv-curlybraces-color": "#ADB1B8", - "--w-rjv-key-string": "#A78BFA", - "--w-rjv-info-color": "#FBBF24", - "--w-rjv-type-string-color": "#34D399", - "--w-rjv-quotes-string-color": "#34D399", - "--w-rjv-type-boolean-color": "#FBBF24", - "--w-rjv-type-int-color": "#60A5FA", - "--w-rjv-type-float-color": "#60A5FA", - "--w-rjv-type-bigint-color": "#60A5FA", - "--w-rjv-key-number": "#60A5FA", + "--w-rjv-line-color": "var(--code-border)", + "--w-rjv-curlybraces-color": "rgb(255,255,255)", + "--w-rjv-key-string": "rgb(247,208,25)", + "--w-rjv-info-color": "var(--code-fg-muted)", + "--w-rjv-type-string-color": "rgb(73,209,110)", + "--w-rjv-quotes-string-color": "rgb(73,209,110)", + "--w-rjv-type-boolean-color": "rgb(187,77,96)", + "--w-rjv-type-int-color": "rgb(247,208,25)", + "--w-rjv-type-float-color": "rgb(247,208,25)", + "--w-rjv-type-bigint-color": "rgb(247,208,25)", + "--w-rjv-key-number": "rgb(247,208,25)", + "--w-rjv-arrow-color": "rgb(255,255,255)", + "--w-rjv-copied-color": "var(--code-fg-subtle)", + "--w-rjv-copied-success-color": "var(--code-fg-base)", + "--w-rjv-colon-color": "rgb(255,255,255)", } as CSSProperties } collapsed={1} > - { - if (copied) { - return ( - - ) - } - return ( - - ) - }} - /> - " "} /> + } /> ( null )} /> + ( + undefined + )} + /> { return ( - {Object.keys(value as object).length} items + {t("general.items", { + count: Object.keys(value as object).length, + })} ) }} /> + {/* + + */} + + : + diff --git a/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx b/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx index 25f181756c..9702645749 100644 --- a/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx +++ b/packages/admin-next/dashboard/src/components/layout/settings-layout/settings-layout.tsx @@ -1,5 +1,6 @@ -import { ArrowUturnLeft } from "@medusajs/icons" +import { ArrowUturnLeft, MinusMini } from "@medusajs/icons" import { IconButton, Text } from "@medusajs/ui" +import * as Collapsible from "@radix-ui/react-collapsible" import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { Link, useLocation } from "react-router-dom" @@ -48,10 +49,24 @@ const useSettingRoutes = (): NavItemProps[] => { label: t("salesChannels.domain"), to: "/settings/sales-channels", }, + ], + [t] + ) +} + +const useDeveloperRoutes = (): NavItemProps[] => { + const { t } = useTranslation() + + return useMemo( + () => [ { label: t("apiKeyManagement.domain"), to: "/settings/api-key-management", }, + { + label: t("executions.domain"), + to: "/settings/executions", + }, ], [t] ) @@ -59,6 +74,7 @@ const useSettingRoutes = (): NavItemProps[] => { const SettingsSidebar = () => { const routes = useSettingRoutes() + const developerRoutes = useDeveloperRoutes() const { t } = useTranslation() const location = useLocation() @@ -80,19 +96,60 @@ const SettingsSidebar = () => { - {t("general.settings")} + {t("nav.settings")}
-
- +
+ +
+
+ + {t("nav.general")} + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + {t("nav.developer")} + + + + + + +
+
+ +
+ +
+
+
) diff --git a/packages/admin-next/dashboard/src/index.css b/packages/admin-next/dashboard/src/index.css index b8cac0738d..5171757173 100644 --- a/packages/admin-next/dashboard/src/index.css +++ b/packages/admin-next/dashboard/src/index.css @@ -31,3 +31,17 @@ @apply bg-ui-bg-subtle text-ui-fg-base; } } + +@layer components { + .worfklow-grid { + background-image: radial-gradient(black 1px, transparent 0); + background-size: 40px 40px; + background: repeat; + } +} + +.worfklow-grid { + background-image: radial-gradient(black 1px, transparent 0); + background-size: 40px 40px; + background: repeat; +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx index 7172a1354d..694aaf19b9 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -688,6 +688,23 @@ const router = createBrowserRouter([ }, ], }, + { + path: "executions", + element: , + handle: { + crumb: () => "Executions", + }, + children: [ + { + path: "", + lazy: () => import("../../routes/executions/execution-list"), + }, + { + path: ":id", + lazy: () => import("../../routes/executions/execution-detail"), + }, + ], + }, ...settingsExtensions, ], }, diff --git a/packages/admin-next/dashboard/src/routes/executions/constants.ts b/packages/admin-next/dashboard/src/routes/executions/constants.ts new file mode 100644 index 0000000000..c02f3cf0ef --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/constants.ts @@ -0,0 +1,26 @@ +import { TransactionState, TransactionStepState } from "./types" + +export const STEP_IN_PROGRESS_STATES = [ + TransactionStepState.COMPENSATING, + TransactionStepState.INVOKING, +] +export const STEP_OK_STATES = [TransactionStepState.DONE] +export const STEP_ERROR_STATES = [ + TransactionStepState.FAILED, + TransactionStepState.REVERTED, + TransactionStepState.TIMEOUT, + TransactionStepState.DORMANT, + TransactionStepState.SKIPPED, +] +export const STEP_INACTIVE_STATES = [TransactionStepState.NOT_STARTED] + +export const TRANSACTION_OK_STATES = [TransactionState.DONE] +export const TRANSACTION_ERROR_STATES = [ + TransactionState.FAILED, + TransactionState.REVERTED, +] +export const TRANSACTION_IN_PROGRESS_STATES = [ + TransactionState.INVOKING, + TransactionState.WAITING_TO_COMPENSATE, + TransactionState.COMPENSATING, +] diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-general-section/execution-general-section.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-general-section/execution-general-section.tsx new file mode 100644 index 0000000000..17eb6f77fe --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-general-section/execution-general-section.tsx @@ -0,0 +1,118 @@ +import { + Badge, + Container, + Copy, + Heading, + StatusBadge, + Text, + clx, +} from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { + TransactionStepState, + WorkflowExecutionDTO, + WorkflowExecutionStep, +} from "../../../types" +import { getTransactionState, getTransactionStateColor } from "../../../utils" + +type ExecutionGeneralSectionProps = { + execution: WorkflowExecutionDTO +} + +export const ExecutionGeneralSection = ({ + execution, +}: ExecutionGeneralSectionProps) => { + const { t } = useTranslation() + + const cleanId = execution.id.replace("wf_exec_", "") + const translatedState = getTransactionState(t, execution.state) + const stateColor = getTransactionStateColor(execution.state) + + return ( + +
+
+ {cleanId} + +
+ {translatedState} +
+
+ + {t("executions.workflowIdLabel")} + + + {execution.workflow_id} + +
+
+ + {t("executions.transactionIdLabel")} + + + {execution.transaction_id} + +
+
+ + {t("executions.progressLabel")} + + +
+
+ ) +} + +const ROOT_PREFIX = "_root" + +const Progress = ({ + steps, +}: { + steps?: Record | null +}) => { + const { t } = useTranslation() + + if (!steps) { + return ( + + {t("executions.stepsCompletedLabel", { + completed: 0, + total: 0, + })} + + ) + } + + const actionableSteps = Object.values(steps).filter( + (step) => step.id !== ROOT_PREFIX + ) + + const completedSteps = actionableSteps.filter( + (step) => step.invoke.state === TransactionStepState.DONE + ) + + return ( +
+
+ {actionableSteps.map((step) => ( +
+ ))} +
+ + {t("executions.stepsCompletedLabel", { + completed: completedSteps.length, + count: actionableSteps.length, + })} + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-general-section/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-general-section/index.ts new file mode 100644 index 0000000000..c8ae4714fe --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-general-section/index.ts @@ -0,0 +1 @@ +export * from "./execution-general-section" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-history-section/execution-history-section.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-history-section/execution-history-section.tsx new file mode 100644 index 0000000000..71f7b60911 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-history-section/execution-history-section.tsx @@ -0,0 +1,320 @@ +import { Spinner, TriangleDownMini } from "@medusajs/icons" +import { + CodeBlock, + Container, + Heading, + IconButton, + Text, + clx, +} from "@medusajs/ui" +import * as Collapsible from "@radix-ui/react-collapsible" +import { format } from "date-fns" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { useLocation } from "react-router-dom" +import { + STEP_ERROR_STATES, + STEP_INACTIVE_STATES, + STEP_IN_PROGRESS_STATES, + STEP_OK_STATES, +} from "../../../constants" +import { + StepError, + StepInvoke, + TransactionStepState, + TransactionStepStatus, + WorkflowExecutionDTO, + WorkflowExecutionStep, +} from "../../../types" + +type ExecutionHistorySectionProps = { + execution: WorkflowExecutionDTO +} + +export const ExecutionHistorySection = ({ + execution, +}: ExecutionHistorySectionProps) => { + const { t } = useTranslation() + + const map = Object.values(execution.execution?.steps || {}) + const steps = map.filter((step) => step.id !== "_root") + + // check if any of the steps have a .invoke.state of "permanent_failure" and if that is the case then return its id + const unreachableStepId = steps.find( + (step) => step.invoke.status === TransactionStepStatus.PERMANENT_FAILURE + )?.id + + // return an array of step ids of all steps that come after the unreachable step if there is one + const unreachableSteps = unreachableStepId + ? steps + .filter( + (step) => + step.id !== unreachableStepId && step.id.includes(unreachableStepId) + ) + .map((step) => step.id) + : [] + + return ( + +
+ {t("executions.history.sectionTitle")} +
+
+ {steps.map((step, index) => { + const stepId = step.id.split(".").pop() + + if (!stepId) { + return null + } + + const context = execution.context?.data.invoke[stepId] + const error = execution.context?.errors.find( + (e) => e.action === stepId + ) + + return ( + + ) + })} +
+
+ ) +} + +const Event = ({ + step, + stepInvokeContext, + stepError, + isLast, + isUnreachable, +}: { + step: WorkflowExecutionStep + stepInvokeContext: StepInvoke | undefined + stepError?: StepError | undefined + isLast: boolean + isUnreachable?: boolean +}) => { + const [open, setOpen] = useState(false) + + const ref = useRef(null) + const { hash } = useLocation() + + const { t } = useTranslation() + + const stepId = step.id.split(".").pop()! + + useEffect(() => { + if (hash === `#${stepId}`) { + setOpen(true) + } + }, [hash, stepId]) + + const identifier = step.id.split(".").pop() + + stepInvokeContext + + return ( +
+
+
+
+
+
+
+
+
+
+
+ + +
+ + {identifier} + +
+ + + + +
+
+
+ +
+
+ + {t("executions.history.definitionLabel")} + + + + +
+ {stepInvokeContext && ( +
+ + {t("executions.history.outputLabel")} + + + + +
+ )} + {!!stepInvokeContext?.output.compensateInput && + step.compensate.state === TransactionStepState.REVERTED && ( +
+ + {t("executions.history.compensateInputLabel")} + + + + +
+ )} + {stepError && ( +
+ + {t("executions.history.errorLabel")} + + + + +
+ )} +
+
+
+
+ ) +} + +const StepState = ({ + state, + startedAt, + isUnreachable, +}: { + state: TransactionStepState + startedAt?: number | null + isUnreachable?: boolean +}) => { + const { t } = useTranslation() + + const isFailed = state === TransactionStepState.FAILED + const isRunning = state === TransactionStepState.INVOKING + + if (isUnreachable) { + return null + } + + if (isRunning) { + return ( +
+ + {t("executions.history.runningState")} + + +
+ ) + } + + if (isFailed) { + return ( + + {t("executions.history.failedState")} + + ) + } + + if (startedAt) { + return ( + + {format(startedAt, "dd MMM yyyy HH:mm:ss")} + + ) + } +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-history-section/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-history-section/index.ts new file mode 100644 index 0000000000..bf1afb788f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-history-section/index.ts @@ -0,0 +1 @@ +export * from "./execution-history-section" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-payload-section/execution-payload-section.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-payload-section/execution-payload-section.tsx new file mode 100644 index 0000000000..ae1860e476 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-payload-section/execution-payload-section.tsx @@ -0,0 +1,24 @@ +import { JsonViewSection } from "../../../../../components/common/json-view-section" +import { WorkflowExecutionDTO } from "../../../types" + +type ExecutionPayloadSectionProps = { + execution: WorkflowExecutionDTO +} + +export const ExecutionPayloadSection = ({ + execution, +}: ExecutionPayloadSectionProps) => { + let payload = execution.context?.data?.payload + + if (!payload) { + return null + } + + // payloads may be simple primitives, so we need to wrap them in an object + // to ensure the JsonViewSection component can render them + if (typeof payload !== "object") { + payload = { input: payload } + } + + return +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-payload-section/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-payload-section/index.ts new file mode 100644 index 0000000000..d3b4a12f25 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-payload-section/index.ts @@ -0,0 +1 @@ +export * from "./execution-payload-section" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-timeline-section/execution-timeline-section.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-timeline-section/execution-timeline-section.tsx new file mode 100644 index 0000000000..740a7aed13 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-timeline-section/execution-timeline-section.tsx @@ -0,0 +1,399 @@ +import { ArrowPathMini, MinusMini, PlusMini } from "@medusajs/icons" +import { Container, Heading, Text, clx } from "@medusajs/ui" +import { + motion, + useAnimationControls, + useDragControls, + useMotionValue, +} from "framer-motion" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +import { + STEP_ERROR_STATES, + STEP_INACTIVE_STATES, + STEP_IN_PROGRESS_STATES, + STEP_OK_STATES, +} from "../../../constants" +import { WorkflowExecutionDTO, WorkflowExecutionStep } from "../../../types" + +type ExecutionTimelineSectionProps = { + execution: WorkflowExecutionDTO +} + +export const ExecutionTimelineSection = ({ + execution, +}: ExecutionTimelineSectionProps) => { + const { t } = useTranslation() + + return ( + +
+ {t("general.timeline")} +
+
+ +
+
+ ) +} + +const createNodeClusters = (steps: Record) => { + const actionableSteps = Object.values(steps).filter( + (step) => step.id !== "_root" + ) + + const clusters: Record = {} + + actionableSteps.forEach((step) => { + if (!clusters[step.depth]) { + clusters[step.depth] = [] + } + + clusters[step.depth].push(step) + }) + + return clusters +} + +const getNextCluster = ( + clusters: Record, + depth: number +) => { + const nextDepth = depth + 1 + return clusters[nextDepth] +} + +type ZoomScale = 0.5 | 0.75 | 1 + +const defaultState = { + x: -860, + y: -1020, + scale: 1, +} + +const MAX_ZOOM = 1.5 +const MIN_ZOOM = 0.5 +const ZOOM_STEP = 0.25 + +const Canvas = ({ execution }: { execution: WorkflowExecutionDTO }) => { + const [zoom, setZoom] = useState(1) + + const scale = useMotionValue(defaultState.scale) + const x = useMotionValue(defaultState.x) + const y = useMotionValue(defaultState.y) + + const controls = useAnimationControls() + + const dragControls = useDragControls() + const dragConstraints = useRef(null) + + const canZoomIn = zoom < MAX_ZOOM + const canZoomOut = zoom > MIN_ZOOM + + useEffect(() => { + const unsubscribe = scale.on("change", (latest) => { + setZoom(latest as ZoomScale) + }) + + return () => { + unsubscribe() + } + }, [scale]) + + const clusters = createNodeClusters(execution.execution?.steps || {}) + + function scaleXandY( + prevScale: number, + newScale: number, + x: number, + y: number + ) { + const scaleRatio = newScale / prevScale + return { + x: x * scaleRatio, + y: y * scaleRatio, + } + } + + const changeZoom = (newScale: number) => { + const { x: newX, y: newY } = scaleXandY(zoom, newScale, x.get(), y.get()) + + setZoom(newScale) + controls.set({ scale: newScale, x: newX, y: newY }) + } + + const zoomIn = () => { + const curr = scale.get() + + if (curr < 1.5) { + const newScale = curr + ZOOM_STEP + changeZoom(newScale) + } + } + + const zoomOut = () => { + const curr = scale.get() + + if (curr > 0.5) { + const newScale = curr - ZOOM_STEP + changeZoom(newScale) + } + } + + const resetCanvas = () => { + controls.start(defaultState) + } + + return ( +
+
+
+
+ +
+
+ {Object.entries(clusters).map(([depth, cluster]) => { + const next = getNextCluster(clusters, Number(depth)) + + return ( +
+
+ {cluster.map((step) => ( + + ))} +
+ +
+ ) + })} +
+
+
+
+
+
+ + + +
+
+
+ ) +} + +const HorizontalArrow = () => { + return ( + + + + ) +} + +const MiddleArrow = () => { + return ( + + + + ) +} + +const EndArrow = () => { + return ( + + + + ) +} + +const Arrow = ({ depth }: { depth: number }) => { + if (depth === 1) { + return + } + + if (depth === 2) { + return ( +
+ + +
+ ) + } + + const inbetween = Array.from({ length: depth - 2 }).map((_, index) => ( + + )) + + return ( +
+ + {inbetween} + +
+ ) +} + +const Line = ({ next }: { next?: WorkflowExecutionStep[] }) => { + if (!next) { + return null + } + + return ( +
+
+
+
+
+
+ +
+
+
+ ) +} + +const Node = ({ step }: { step: WorkflowExecutionStep }) => { + if (step.id === "_root") { + return null + } + + const stepId = step.id.split(".").pop() + + /** + * We can't rely on the built-in hash scrolling because the collapsible, + * so we instead need to manually scroll to the step when the hash changes + */ + const handleScrollTo = () => { + if (!stepId) { + return + } + + const historyItem = document.getElementById(stepId) + + if (!historyItem) { + return + } + + /** + * Scroll to the step if it's the one we're looking for but + * we need to wait for the collapsible to open before scrolling + */ + setTimeout(() => { + historyItem.scrollIntoView({ + behavior: "smooth", + block: "end", + }) + }, 100) + } + + return ( + +
+
+
+
+ + {stepId} + +
+ + ) +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-timeline-section/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-timeline-section/index.ts new file mode 100644 index 0000000000..4acff81887 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/components/execution-timeline-section/index.ts @@ -0,0 +1 @@ +export * from "./execution-timeline-section" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/execution-detail.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-detail/execution-detail.tsx new file mode 100644 index 0000000000..cabf1996a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/execution-detail.tsx @@ -0,0 +1,35 @@ +import { useAdminCustomQuery } from "medusa-react" +import { useParams } from "react-router-dom" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { adminExecutionKey } from "../utils" +import { ExecutionGeneralSection } from "./components/execution-general-section" +import { ExecutionHistorySection } from "./components/execution-history-section" +import { ExecutionPayloadSection } from "./components/execution-payload-section" +import { ExecutionTimelineSection } from "./components/execution-timeline-section" + +export const ExecutionDetail = () => { + const { id } = useParams() + + const { data, isLoading, isError, error } = useAdminCustomQuery( + `/workflows-executions/${id}`, + adminExecutionKey.detail(id!) + ) + + if (isLoading || !data) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( +
+ + + + + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-detail/index.ts new file mode 100644 index 0000000000..d939b77df5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/index.ts @@ -0,0 +1 @@ +export { ExecutionDetail as Component } from "./execution-detail" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-detail/loader.ts b/packages/admin-next/dashboard/src/routes/executions/execution-detail/loader.ts new file mode 100644 index 0000000000..b1037283fb --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-detail/loader.ts @@ -0,0 +1,21 @@ +import { AdminProductsRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminProductKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const executionDetailQuery = (id: string) => ({ + queryKey: adminProductKeys.detail(id), + queryFn: async () => medusa.admin.custom.get(`/workflows-executions/${id}`), +}) + +export const executionLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = executionDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/executions-list-table.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/executions-list-table.tsx new file mode 100644 index 0000000000..d921635dc6 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/executions-list-table.tsx @@ -0,0 +1,78 @@ +import { AdminGetWorkflowExecutionsParams } from "@medusajs/medusa" +import { Container, Heading } from "@medusajs/ui" +import { useAdminCustomQuery } from "medusa-react" +import { useTranslation } from "react-i18next" +import { DataTable } from "../../../../../components/table/data-table" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { WorkflowExecutionDTO } from "../../../types" +import { adminExecutionKey } from "../../../utils" +import { useExecutionTableColumns } from "./use-execution-table-columns" +import { useExecutionTableQuery } from "./use-execution-table-query" + +/** + * Type isn't exported from the package + */ +type WorkflowExecutionsRes = { + workflow_executions: WorkflowExecutionDTO[] + count: number + offset: number + limit: number +} + +const PAGE_SIZE = 20 + +export const ExecutionsListTable = () => { + const { t } = useTranslation() + + const { searchParams, raw } = useExecutionTableQuery({ + pageSize: PAGE_SIZE, + }) + const { data, isLoading, isError, error } = useAdminCustomQuery< + AdminGetWorkflowExecutionsParams, + WorkflowExecutionsRes + >( + "/workflows-executions", + adminExecutionKey.list(searchParams), + { + ...searchParams, + fields: "execution,state", + }, + { + keepPreviousData: true, + } + ) + + const columns = useExecutionTableColumns() + + const { table } = useDataTable({ + data: data?.workflow_executions || [], + columns, + count: data?.count, + pageSize: PAGE_SIZE, + enablePagination: true, + getRowId: (row) => row.id, + }) + + if (isError) { + throw error + } + + return ( + +
+ {t("executions.domain")} +
+ `${row.id}`} + search + pagination + queryObject={raw} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/index.ts new file mode 100644 index 0000000000..d23ad6ee55 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/index.ts @@ -0,0 +1 @@ +export * from "./executions-list-table" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/use-execution-table-columns.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/use-execution-table-columns.tsx new file mode 100644 index 0000000000..9852a2fbe0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/use-execution-table-columns.tsx @@ -0,0 +1,72 @@ +import { Badge } from "@medusajs/ui" +import { ColumnDef, createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell" +import { + TransactionStepState, + WorkflowExecutionDTO, + WorkflowExecutionStep, +} from "../../../types" +import { getTransactionState, getTransactionStateColor } from "../../../utils" + +const columnHelper = createColumnHelper() + +export const useExecutionTableColumns = (): ColumnDef< + WorkflowExecutionDTO, + any +>[] => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("transaction_id", { + header: t("executions.transactionIdLabel"), + cell: ({ getValue }) => {getValue()}, + }), + columnHelper.accessor("state", { + header: t("fields.state"), + cell: ({ getValue }) => { + const state = getValue() + + const color = getTransactionStateColor(state) + const translatedState = getTransactionState(t, state) + + return ( + + {translatedState} + + ) + }, + }), + columnHelper.accessor("execution", { + header: t("executions.progressLabel"), + cell: ({ getValue }) => { + const steps = getValue()?.steps as + | Record + | undefined + + if (!steps) { + return "0 of 0 steps" + } + + const actionableSteps = Object.values(steps).filter( + (step) => step.id !== ROOT_PREFIX + ) + + const completedSteps = actionableSteps.filter( + (step) => step.invoke.state === TransactionStepState.DONE + ) + + return t("executions.stepsCompletedLabel", { + completed: completedSteps.length, + count: actionableSteps.length, + }) + }, + }), + ], + [t] + ) +} + +const ROOT_PREFIX = "_root" diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/use-execution-table-query.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/use-execution-table-query.tsx new file mode 100644 index 0000000000..5057e82f83 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-list/components/executions-list-table/use-execution-table-query.tsx @@ -0,0 +1,25 @@ +import { AdminGetWorkflowExecutionsParams } from "@medusajs/medusa" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useExecutionTableQuery = ({ + pageSize = 20, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams(["q", "offset"], prefix) + + const { offset, ...rest } = raw + + const searchParams: AdminGetWorkflowExecutionsParams = { + limit: pageSize, + offset: offset ? parseInt(offset) : 0, + ...rest, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-list/execution-list.tsx b/packages/admin-next/dashboard/src/routes/executions/execution-list/execution-list.tsx new file mode 100644 index 0000000000..4fd985c946 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-list/execution-list.tsx @@ -0,0 +1,9 @@ +import { ExecutionsListTable } from "./components/executions-list-table" + +export const ExcecutionList = () => { + return ( +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/executions/execution-list/index.ts b/packages/admin-next/dashboard/src/routes/executions/execution-list/index.ts new file mode 100644 index 0000000000..2cfda278ad --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/execution-list/index.ts @@ -0,0 +1 @@ +export { ExcecutionList as Component } from "./execution-list" diff --git a/packages/admin-next/dashboard/src/routes/executions/types.ts b/packages/admin-next/dashboard/src/routes/executions/types.ts new file mode 100644 index 0000000000..eacd3d964f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/types.ts @@ -0,0 +1,95 @@ +export type WorkflowExecutionStep = { + id: string + invoke: { + state: TransactionStepState + status: TransactionStepStatus + } + definition: { + async?: boolean + compensateAsync?: boolean + noCompensation?: boolean + continueOnPermanentFailure?: boolean + maxRetries?: number + noWait?: boolean + retryInterval?: number + retryIntervalAwaiting?: number + saveResponse?: boolean + timeout?: number + } + compensate: { + state: TransactionStepState + status: TransactionStepStatus + } + depth: number + startedAt: number +} + +export type StepInvoke = { + output: { + output: unknown + compensateInput: unknown + } +} + +export type StepError = { + error: Record + action: string + handlerType: string +} + +export type WorkflowExecutionContext = { + data: { + invoke: Record + payload?: unknown + } + compensate: Record + errors: StepError[] +} + +type WorflowExecutionExecution = { + steps: Record +} + +/** + * Re-implements WorkflowExecutionDTO as it is currently only exported from `@medusajs/workflows-sdk`. + * Also adds type definitions for fields that have vague types, such as `execution` and `context`. + */ +export interface WorkflowExecutionDTO { + id: string + workflow_id: string + transaction_id: string + execution: WorflowExecutionExecution | null + context: WorkflowExecutionContext | null + state: any + created_at: Date + updated_at: Date + deleted_at: Date +} + +export enum TransactionStepStatus { + IDLE = "idle", + OK = "ok", + WAITING = "waiting_response", + TEMPORARY_FAILURE = "temp_failure", + PERMANENT_FAILURE = "permanent_failure", +} +export enum TransactionState { + NOT_STARTED = "not_started", + INVOKING = "invoking", + WAITING_TO_COMPENSATE = "waiting_to_compensate", + COMPENSATING = "compensating", + DONE = "done", + REVERTED = "reverted", + FAILED = "failed", +} +export enum TransactionStepState { + NOT_STARTED = "not_started", + INVOKING = "invoking", + COMPENSATING = "compensating", + DONE = "done", + REVERTED = "reverted", + FAILED = "failed", + DORMANT = "dormant", + SKIPPED = "skipped", + TIMEOUT = "timeout", +} diff --git a/packages/admin-next/dashboard/src/routes/executions/utils.ts b/packages/admin-next/dashboard/src/routes/executions/utils.ts new file mode 100644 index 0000000000..c0da09a8ee --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/executions/utils.ts @@ -0,0 +1,101 @@ +import { AdminGetWorkflowExecutionsParams } from "@medusajs/medusa" +import { TFunction } from "i18next" +import { + STEP_ERROR_STATES, + STEP_INACTIVE_STATES, + STEP_IN_PROGRESS_STATES, + TRANSACTION_ERROR_STATES, + TRANSACTION_IN_PROGRESS_STATES, +} from "./constants" +import { TransactionState, TransactionStepState } from "./types" + +export const adminExecutionKey = { + detail: (id: string) => ["workflow_executions", "detail", id], + list: (query?: AdminGetWorkflowExecutionsParams) => [ + "workflow_executions", + "list", + { query }, + ], +} + +export const getTransactionStateColor = ( + state: TransactionState +): "green" | "orange" | "red" => { + let statusColor: "green" | "red" | "orange" = "green" + + if (TRANSACTION_ERROR_STATES.includes(state)) { + statusColor = "red" + } + + if (TRANSACTION_IN_PROGRESS_STATES.includes(state)) { + statusColor = "orange" + } + + return statusColor +} + +export const getTransactionState = ( + t: TFunction<"translation", any>, + state: TransactionState +) => { + switch (state) { + case TransactionState.DONE: + return t("executions.state.done") + case TransactionState.FAILED: + return t("executions.state.failed") + case TransactionState.REVERTED: + return t("executions.state.reverted") + case TransactionState.INVOKING: + return t("executions.state.invoking") + case TransactionState.WAITING_TO_COMPENSATE: + return t("executions.transaction.state.waitingToCompensate") + case TransactionState.COMPENSATING: + return t("executions.state.compensating") + case TransactionState.NOT_STARTED: + return t("executions.state.notStarted") + } +} + +export const getStepStateColor = (state: TransactionStepState) => { + let statusColor: "green" | "red" | "orange" | "grey" = "green" + + if (STEP_ERROR_STATES.includes(state)) { + statusColor = "red" + } + + if (STEP_INACTIVE_STATES.includes(state)) { + statusColor = "grey" + } + + if (STEP_IN_PROGRESS_STATES.includes(state)) { + statusColor = "orange" + } + + return statusColor +} + +export const getStepState = ( + t: TFunction<"translation", any>, + state: TransactionStepState +) => { + switch (state) { + case TransactionStepState.DONE: + return t("executions.state.done") + case TransactionStepState.FAILED: + return t("executions.state.failed") + case TransactionStepState.REVERTED: + return t("executions.state.reverted") + case TransactionStepState.INVOKING: + return t("executions.state.invoking") + case TransactionStepState.COMPENSATING: + return t("executions.state.compensating") + case TransactionStepState.NOT_STARTED: + return t("executions.state.notStarted") + case TransactionStepState.SKIPPED: + return t("executions.step.state.skipped") + case TransactionStepState.DORMANT: + return t("executions.step.state.dormant") + case TransactionStepState.TIMEOUT: + return t("executions.step.state.timeout") + } +} diff --git a/packages/design-system/ui/package.json b/packages/design-system/ui/package.json index 97ca652454..f6d066f94c 100644 --- a/packages/design-system/ui/package.json +++ b/packages/design-system/ui/package.json @@ -105,6 +105,7 @@ "cva": "1.0.0-beta.1", "date-fns": "^2.30.0", "prism-react-renderer": "^2.0.6", + "prismjs": "^1.29.0", "react-currency-input-field": "^3.6.11", "react-day-picker": "^8.8.0", "tailwind-merge": "^2.2.1" diff --git a/packages/design-system/ui/src/components/code-block/code-block.tsx b/packages/design-system/ui/src/components/code-block/code-block.tsx index c8723eeccd..8013742a8d 100644 --- a/packages/design-system/ui/src/components/code-block/code-block.tsx +++ b/packages/design-system/ui/src/components/code-block/code-block.tsx @@ -1,6 +1,10 @@ "use client" -import { Highlight, themes } from "prism-react-renderer" +import { Highlight, Prism, themes } from "prism-react-renderer" import * as React from "react" +;(typeof global !== "undefined" ? global : window).Prism = Prism + +// @ts-ignore +import("prismjs/components/prism-json") import { Copy } from "@/components/copy" import { clx } from "@/utils/clx" @@ -215,6 +219,12 @@ const Body = ({ color: "rgb(247,208,25)", }, }, + { + types: ["property"], + style: { + color: "rgb(247,208,25)", + }, + }, { types: ["maybe-class-name"], style: { @@ -230,7 +240,7 @@ const Body = ({ { types: ["comment"], style: { - color: "rgb(52,211,153)", + color: "var(--code-fg-subtle)", }, }, ], diff --git a/packages/medusa/src/api/routes/admin/workflows-executions/index.ts b/packages/medusa/src/api/routes/admin/workflows-executions/index.ts index 092568bf04..c539ae5431 100644 --- a/packages/medusa/src/api/routes/admin/workflows-executions/index.ts +++ b/packages/medusa/src/api/routes/admin/workflows-executions/index.ts @@ -81,3 +81,4 @@ export default (app) => { } export * from "./query-config" +export * from "./validators" diff --git a/yarn.lock b/yarn.lock index 3eda1edccb..e1a76c8a27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8156,7 +8156,7 @@ __metadata: "@types/node": ^20.11.15 "@types/react": 18.2.43 "@types/react-dom": 18.2.17 - "@uiw/react-json-view": 2.0.0-alpha.10 + "@uiw/react-json-view": ^2.0.0-alpha.17 "@vitejs/plugin-react": 4.2.1 autoprefixer: ^10.4.17 cmdk: ^0.2.0 @@ -9047,6 +9047,7 @@ __metadata: jsdom: ^22.1.0 postcss: ^8.4.33 prism-react-renderer: ^2.0.6 + prismjs: ^1.29.0 prop-types: ^15.8.1 react: ^18.2.0 react-currency-input-field: ^3.6.11 @@ -18958,14 +18959,14 @@ __metadata: languageName: node linkType: hard -"@uiw/react-json-view@npm:2.0.0-alpha.10": - version: 2.0.0-alpha.10 - resolution: "@uiw/react-json-view@npm:2.0.0-alpha.10" +"@uiw/react-json-view@npm:^2.0.0-alpha.17": + version: 2.0.0-alpha.17 + resolution: "@uiw/react-json-view@npm:2.0.0-alpha.17" peerDependencies: "@babel/runtime": ">=7.10.0" react: ">=18.0.0" react-dom: ">=18.0.0" - checksum: d1278e92320251b1b61ecacd5c701989bd732ce4155b14a00b674d151c5b0da1b4a043b8696d23675006bf345559e56492960e33a9c0f40bc3c18eed031a136d + checksum: 6c8f10af40db9ae60da4b799fc391a544d2e6eec071d882d64050869b057e26fa6104c00de0406693a341c03a8017cb37c14136d803c9ff60e86c6e0d0187cbc languageName: node linkType: hard @@ -43008,7 +43009,7 @@ __metadata: languageName: node linkType: hard -"prismjs@npm:^1.27.0": +"prismjs@npm:^1.27.0, prismjs@npm:^1.29.0": version: 1.29.0 resolution: "prismjs@npm:1.29.0" checksum: d906c4c4d01b446db549b4f57f72d5d7e6ccaca04ecc670fb85cea4d4b1acc1283e945a9cbc3d81819084a699b382f970e02f9d1378e14af9808d366d9ed7ec6