Compare commits

...

39 Commits

Author SHA1 Message Date
Alessio Gravili
bde7f89aad Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-16 11:45:21 -07:00
Alessio Gravili
0332a8add5 fix: do not crash visualize page if there are no logs 2024-12-13 09:17:20 -07:00
Alessio Gravili
9067b37141 undo diff 2024-12-12 09:33:26 -07:00
Alessio Gravili
9742de34b4 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-12 09:32:00 -07:00
Alessio Gravili
5e04db9c08 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-11 11:12:11 -07:00
Alessio Gravili
15a5791f33 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-11 11:10:42 -07:00
Alessio Gravili
4044ffbb61 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-10 18:05:22 -07:00
Alessio Gravili
85da7f235d upgrade dependencies 2024-12-09 12:47:08 -07:00
Alessio Gravili
a2cd308b26 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-09 12:45:31 -07:00
Alessio Gravili
2a8fa30789 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-05 11:01:00 -07:00
Alessio Gravili
68fed44e42 allow customizing min height of code editor 2024-12-05 11:00:50 -07:00
Alessio Gravili
b6681a9c02 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-05 10:06:58 -07:00
Alessio Gravili
bbdf4b1278 memoize tabs, fix error when switching between tabs 2024-12-05 10:05:47 -07:00
Alessio Gravili
78b94b1a42 remove useless console logs 2024-12-05 10:00:58 -07:00
Alessio Gravili
417e3d2437 ensure panel content has max height 2024-12-04 19:01:34 -07:00
Alessio Gravili
20ade34240 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-04 18:52:38 -07:00
Alessio Gravili
3e789278ea Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-02 21:15:59 -07:00
Alessio Gravili
4364ab5a6a export properly 2024-12-02 21:15:46 -07:00
Alessio Gravili
9cc55dc5e1 export importHandlerPath 2024-12-02 21:15:25 -07:00
Alessio Gravili
6302476c30 update pnpm-lock 2024-12-02 17:13:28 -07:00
Alessio Gravili
9217d94d46 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-02 17:08:55 -07:00
Alessio Gravili
363f315ac6 fix CI tests 2024-12-01 21:23:25 -07:00
Alessio Gravili
56807534d2 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-01 20:57:26 -07:00
Alessio Gravili
9a1f8e1674 use correct code editor import 2024-12-01 20:32:09 -07:00
Alessio Gravili
a417842d9a Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-01 20:31:13 -07:00
Alessio Gravili
6300f608e1 use CodeEditor for input and output 2024-12-01 19:50:00 -07:00
Alessio Gravili
8fc036d32b use correct imports 2024-12-01 19:43:27 -07:00
Alessio Gravili
aa944e3f32 Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-01 19:42:34 -07:00
Alessio Gravili
8026b6e4ea ensure tab index is reset to 0 when switching between nodes 2024-12-01 17:49:38 -07:00
Alessio Gravili
fe28238297 use tabs for node panel 2024-12-01 17:39:39 -07:00
Alessio Gravili
25cfceb933 improve node style 2024-12-01 17:17:10 -07:00
Alessio Gravili
0eadb5c2c5 ensure input and output task data is displayed 2024-12-01 17:12:22 -07:00
Alessio Gravili
c28ca5e606 simplify selection handling, support multiple selections 2024-12-01 17:00:18 -07:00
Alessio Gravili
dbb2db9703 fix light mode error display 2024-12-01 14:58:45 -07:00
Alessio Gravili
9b9fea698e improve test tasks, fix lint 2024-12-01 14:50:41 -07:00
Alessio Gravili
33ddfecef2 display task errors nicely in task panel 2024-12-01 14:40:31 -07:00
Alessio Gravili
0149a55e3d make it look pretty 2024-12-01 14:17:18 -07:00
Alessio Gravili
73c703649e Merge remote-tracking branch 'origin/main' into feat/visualize-jobs 2024-12-01 13:58:11 -07:00
Alessio Gravili
8689306b9a feat: job visualization plugin 2024-12-01 13:19:00 -07:00
27 changed files with 1203 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json

View File

@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1 @@
# Payload Visual Jobs Plugin

View File

@@ -0,0 +1,18 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,99 @@
{
"name": "@payloadcms/plugin-visual-jobs",
"version": "3.2.2",
"description": "Visual Jobs plugin for Payload",
"keywords": [
"payload",
"cms",
"plugin",
"typescript",
"react"
],
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/plugin-visual-jobs"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"import": "./src/exports/client.ts",
"types": "./src/exports/client.ts",
"default": "./src/exports/client.ts"
},
"./rsc": {
"import": "./src/exports/rsc.ts",
"types": "./src/exports/rsc.ts",
"default": "./src/exports/rsc.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"dependencies": {
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"@xyflow/react": "^12.3.6"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/next": "workspace:*",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*",
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"import": "./dist/exports/client.js",
"types": "./dist/exports/client.d.ts",
"default": "./dist/exports/client.js"
},
"./rsc": {
"import": "./dist/exports/rsc.js",
"types": "./dist/exports/rsc.d.ts",
"default": "./dist/exports/rsc.js"
}
},
"main": "./dist/index.js",
"registry": "https://registry.npmjs.org/",
"types": "./dist/index.d.ts"
},
"homepage:": "https://payloadcms.com"
}

View File

@@ -0,0 +1 @@
'use client'

View File

@@ -0,0 +1 @@
export { JobsView } from '../ui/JobsView/index.js'

View File

@@ -0,0 +1,42 @@
import type { Config } from 'payload'
import type { VisualJobsPluginConfig } from './types.js'
export const visualJobsPlugin =
(pluginConfig: VisualJobsPluginConfig) =>
(config: Config): Config => {
return {
...config,
jobs: {
...config.jobs,
jobsCollectionOverrides: ({ defaultJobsCollection }) => {
return {
...defaultJobsCollection,
admin: {
...(defaultJobsCollection.admin || {}),
components: {
...(defaultJobsCollection.admin?.components || {}),
views: {
...(defaultJobsCollection.admin?.components?.views || {}),
edit: {
jobs: {
Component: '@payloadcms/plugin-visual-jobs/rsc#JobsView',
path: '/visualize',
tab: {
href: '/visualize',
label: 'Visualize',
},
},
},
},
},
hidden: false,
},
}
},
tasks: [...(config.jobs?.tasks || [])],
},
}
}

View File

@@ -0,0 +1 @@
export type VisualJobsPluginConfig = {}

View File

@@ -0,0 +1,44 @@
'use client'
import { TabComponent, TabsProvider } from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
export type PanelTab = {
Content: React.ReactNode
name: string
}
const baseClass = 'tabs-field'
type Props = {
tabs: PanelTab[]
}
export const Tabs: React.FC<Props> = (props) => {
const { tabs } = props
const [activeTabIndex, setActiveTabIndex] = useState<number>(0)
useEffect(() => {
setActiveTabIndex(0)
}, [tabs])
return (
<TabsProvider>
<div className={`${baseClass}__tabs-wrap`}>
<div className={`${baseClass}__tabs`}>
{tabs.map((tab, tabIndex) => {
return (
<TabComponent
isActive={activeTabIndex === tabIndex}
key={tabIndex}
parentPath={''}
setIsActive={() => {
setActiveTabIndex(tabIndex)
}}
tab={{ name: tab.name, fields: [] }}
/>
)
})}
</div>
</div>
<div className={`${baseClass}__content-wrap`}>{tabs[activeTabIndex]?.Content}</div>
</TabsProvider>
)
}

View File

@@ -0,0 +1,98 @@
@import '~@payloadcms/ui/scss';
.nodePanel {
display: flex;
flex-direction: column;
padding: base(1);
border: 1px solid var(--theme-elevation-100);
background: var(--theme-input-bg);
width: 400px;
.tabs-field__tabs::before {
content: none;
}
.tabs-field__content-wrap {
padding-left: 0;
padding-right: 0;
max-height: 800px;
overflow-y: auto;
}
.task-details {
display: flex;
flex-direction: column;
gap: 8px;
.detail-row {
display: flex;
align-items: flex-start;
.label {
min-width: 100px;
font-weight: 600;
}
.value {
flex: 1;
&.state {
text-transform: capitalize;
font-weight: 600;
&.succeeded {
color: #2ecc71;
}
&.failed {
color: #e74c3c;
}
&.running {
color: #3498db;
}
}
}
}
}
.error-container {
margin-top: 12px;
padding: 12px;
background: var(--theme-error-300);
color: var(--theme-elevation-1000);
border: 1px solid var(--theme-error-100);
border-radius: 6px;
max-width: 400px;
.error-type {
font-weight: 600;
}
.stack-trace {
margin-top: 8px;
.stack-header {
font-weight: 600;
margin-bottom: 4px;
}
.stack-content {
background: var(--theme-error-200);
border: 1px solid var(--theme-error-400);
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
.stack-line {
padding: 2px 0;
white-space: pre-wrap;
word-break: break-all;
}
}
}
}
}

View File

@@ -0,0 +1,134 @@
'use client'
import type { JobLog } from 'payload'
import { CodeEditorLazy } from '@payloadcms/ui'
import './index.scss'
import { Panel, useNodes } from '@xyflow/react'
import React, { useMemo } from 'react'
import { type PanelTab, Tabs } from './Tabs.js'
const ErrorDisplay = ({ error }: { error: any }) => {
const formatStack = (stack: string) => {
return stack.split('\n').map((line, index) => (
<div className="stack-line" key={index}>
{line.trim()}
</div>
))
}
return (
<div className="error-container">
<div className="detail-row">
<span className="label">Error Type:</span>
<span className="value error-type">{error.name}</span>
</div>
<div className="detail-row">
<span className="label">Message:</span>
<span className="value error-message">{error.message}</span>
</div>
{error.stack && (
<div className="stack-trace">
<div className="stack-header">Stack Trace:</div>
<div className="stack-content">{formatStack(error.stack)}</div>
</div>
)}
</div>
)
}
export const NodePanel = () => {
const nodes = useNodes()
const tabs = useMemo(() => {
const selectedNodes = nodes.filter((node) => node.selected)
if (selectedNodes.length !== 1) {
return null
}
const taskLog = selectedNodes[0].data as JobLog
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString()
}
const baseTabs: PanelTab[] = [
{
name: 'Metadata',
Content: (
<div className="task-details">
<div className="detail-row">
<span className="label">Task ID:</span>
<span className="value">{taskLog.taskID}</span>
</div>
<div className="detail-row">
<span className="label">Task Slug:</span>
<span className="value">{taskLog.taskSlug}</span>
</div>
<div className="detail-row">
<span className="label">State:</span>
<span className={`value state ${taskLog.state}`}>{taskLog.state}</span>
</div>
<div className="detail-row">
<span className="label">Executed At:</span>
<span className="value">{formatDate(taskLog.executedAt)}</span>
</div>
<div className="detail-row">
<span className="label">Completed At:</span>
<span className="value">{formatDate(taskLog.completedAt)}</span>
</div>
<div className="detail-row">
<span className="label">ID:</span>
<span className="value">{taskLog.id}</span>
</div>
{taskLog.error ? <ErrorDisplay error={taskLog.error} /> : null}
</div>
),
},
]
if (taskLog.input && Object.keys(taskLog.input).length > 0) {
baseTabs.push({
name: 'Input',
Content: (
<CodeEditorLazy
key="input"
language="json"
minHeight={168}
readOnly={true}
value={JSON.stringify(taskLog.input, null, 2)}
/>
),
})
}
if (taskLog.output && Object.keys(taskLog.output).length > 0) {
baseTabs.push({
name: 'Output',
Content: (
<CodeEditorLazy
key="output"
language="json"
minHeight={168}
readOnly={true}
value={JSON.stringify(taskLog.output, null, 2)}
/>
),
})
}
return baseTabs
}, [nodes])
if (!tabs) {
return null
}
return (
<Panel className="nodePanel" position="top-right">
<Tabs tabs={tabs} />
</Panel>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import {
SelectInput,
SetDocumentStepNav,
useConfig,
useDocumentInfo,
useTheme,
} from '@payloadcms/ui'
import {
applyEdgeChanges,
applyNodeChanges,
Background,
Controls,
type Edge,
type Node,
ReactFlow,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import React, { useCallback, useEffect, useState } from 'react'
import type { RetrySequences } from './utilities/logsToRetrySequences.js'
import { NodePanel } from './components/NodePanel/index.js'
import { TaskNode } from './nodes/taskNode/index.js'
const nodeTypes = {
taskNode: TaskNode,
}
export const JobsViewClient: React.FC<{ retrySequences: RetrySequences }> = (props) => {
const { retrySequences } = props
const { theme } = useTheme()
const { getEntityConfig } = useConfig()
const { id } = useDocumentInfo()
const collectionConfig = getEntityConfig({
collectionSlug: 'payload-jobs',
}) as ClientCollectionConfig
const [run, setRun] = React.useState<string>(String(retrySequences.length - 1))
const log = retrySequences[Number(run)]
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
useEffect(() => {
setNodes(
log.map((entry, index) => {
return {
id: entry.id,
type: 'taskNode',
data: entry,
position: { x: 0, y: index * 100 },
} as Node
}),
)
setEdges(
log
.map((entry, index) => {
if (index === 0) {
return
}
return {
id: `e${log[index - 1].id}-${entry.id}`,
markerEnd: {
type: 'arrowclosed',
color: theme === 'light' ? 'black' : 'white',
height: 25,
strokeWidth: 2,
width: 25,
},
// Set completion time between entry.completedAt and log[index - 1].completedAt
label: `${String(
new Date(entry.completedAt).getTime() -
new Date(log[index - 1].completedAt).getTime(),
)} ms`,
source: log[index - 1].id,
target: entry.id,
} as Edge
})
.filter(Boolean),
)
}, [log, retrySequences, theme])
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
)
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[],
)
return (
<div>
<SetDocumentStepNav
collectionSlug={'payload-jobs'}
globalLabel={undefined}
globalSlug={undefined}
id={id}
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
view="Visualize"
/>
<SelectInput
label={'Job Run'}
name="run"
onChange={(option) => {
if (Array.isArray(option)) {
return
}
const value = option.value
setRun(value as string)
}}
options={retrySequences
.map((sequence, index) => {
if (index === retrySequences.length - 1) {
return {
label: 'Latest',
value: String(index),
}
}
return {
label: `Run ${index + 1}`,
value: String(index),
}
})
.reverse()}
path="run"
value={run}
/>
<div style={{ height: '1000px', width: '100%' }}>
<br />
<ReactFlow
colorMode={theme}
edges={edges}
nodes={nodes}
nodesConnectable={false}
nodeTypes={nodeTypes}
onEdgesChange={onEdgesChange}
onNodesChange={onNodesChange}
>
<Background />
<Controls />
<NodePanel />
</ReactFlow>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import type { ServerSideEditViewProps } from 'payload'
import { Gutter } from '@payloadcms/ui'
import React from 'react'
import { JobsViewClient } from './index.client.js'
import { logsToRetrySequences } from './utilities/logsToRetrySequences.js'
export const JobsView: React.FC<ServerSideEditViewProps> = (props) => {
const { doc } = props
const retrySequences = logsToRetrySequences(doc.log)
if (!retrySequences?.length) {
return (
<Gutter>
<p>No logs to display</p>
</Gutter>
)
}
return (
<Gutter>
<br />
<JobsViewClient retrySequences={retrySequences} />
</Gutter>
)
}

View File

@@ -0,0 +1,31 @@
@import '~@payloadcms/ui/scss';
.taskNode {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1em;
width: 100%;
height: 100%;
padding: 10px;
border: 1px solid var(--theme-elevation-100);
background: var(--theme-input-bg);
&__label {
display: flex;
flex-direction: column;
}
&__taskID {
font-size: 1.1rem;
}
&__id {
color: var(--theme-elevation-600);
font-size: 0.85rem;
}
}
.selected .taskNode {
outline: 1px solid var(--theme-elevation-800);
}

View File

@@ -0,0 +1,38 @@
'use client'
import type { JobLog } from 'payload'
import './index.scss'
import { ErrorIcon, SuccessIcon } from '@payloadcms/ui'
import { Handle, Position } from '@xyflow/react'
import React, { memo } from 'react'
export const TaskNode: React.FC<{
data: JobLog
isConnectable: boolean
}> = memo(({ data, isConnectable }) => {
return (
<>
<Handle
isConnectable={isConnectable}
onConnect={(params) => console.log('handle onConnect', params)}
position={Position.Top}
type="target"
/>
<div
className={[
'taskNode',
data.state === 'succeeded' ? 'taskNode--succeeded' : 'taskNode--failed',
].join(' ')}
>
{data.state === 'succeeded' ? <SuccessIcon /> : <ErrorIcon />}
<div className="taskNode__label">
<strong className="taskNode__taskID">{data.taskID}</strong>
<span className="taskNode__id">{data.id}</span>
</div>
</div>
<Handle id="a" isConnectable={isConnectable} position={Position.Bottom} type="source" />
</>
)
})

View File

@@ -0,0 +1,46 @@
import type { JobLog } from 'payload'
export type RetrySequences = JobLog[][]
export function logsToRetrySequences(logs: JobLog[]): RetrySequences {
const sequences: RetrySequences = []
// Group logs by taskID
const groupedByTaskID = new Map<string, JobLog[]>()
logs.forEach((log) => {
if (!groupedByTaskID.has(log.taskID)) {
groupedByTaskID.set(log.taskID, [])
}
groupedByTaskID.get(log.taskID).push(log)
})
let curSequence: JobLog[] = []
let i = 0
for (const log of logs) {
i++
if (curSequence.length === 0 && sequences.length > 0) {
const lastSequence = sequences[sequences.length - 1]
let i = 0
for (const entry of lastSequence) {
i++
if (i === lastSequence.length) {
break
}
curSequence.push(entry)
}
}
curSequence.push(log)
if (log.state === 'failed') {
sequences.push([...curSequence])
curSequence = []
}
if (i === logs.length) {
sequences.push([...curSequence])
}
}
return sequences
}

View File

@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true, // Make sure typescript knows that this module depends on their references
"noEmit": false /* Do not emit outputs. */,
"emitDeclarationOnly": true,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"rootDir": "./src", /* Specify the root folder within your source files. */
"jsx": "react-jsx"
},
"exclude": [
"dist",
"build",
"tests",
"test",
"node_modules",
"eslint.config.js",
"src/**/*.spec.js",
"src/**/*.spec.jsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
"references": [{ "path": "../payload" }, { "path": "../ui" }, { "path": "../next" }]
}

217
pnpm-lock.yaml generated
View File

@@ -1182,6 +1182,40 @@ importers:
specifier: workspace:*
version: link:../payload
packages/plugin-visual-jobs:
dependencies:
'@payloadcms/translations':
specifier: workspace:*
version: link:../translations
'@payloadcms/ui':
specifier: workspace:*
version: link:../ui
'@xyflow/react':
specifier: ^12.3.6
version: 12.3.6(@types/react@19.0.1)(immer@9.0.21)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
devDependencies:
'@payloadcms/eslint-config':
specifier: workspace:*
version: link:../eslint-config
'@payloadcms/next':
specifier: workspace:*
version: link:../next
'@types/react':
specifier: 19.0.1
version: 19.0.1
'@types/react-dom':
specifier: 19.0.1
version: 19.0.1
payload:
specifier: workspace:*
version: link:../payload
packages/richtext-lexical:
dependencies:
'@faceless-ui/modal':
@@ -1679,6 +1713,9 @@ importers:
'@payloadcms/plugin-stripe':
specifier: workspace:*
version: link:../packages/plugin-stripe
'@payloadcms/plugin-visual-jobs':
specifier: workspace:*
version: link:../packages/plugin-visual-jobs
'@payloadcms/richtext-lexical':
specifier: workspace:*
version: link:../packages/richtext-lexical
@@ -5003,6 +5040,24 @@ packages:
'@types/connect@3.4.36':
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -5413,6 +5468,15 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
'@xyflow/react@12.3.6':
resolution: {integrity: sha512-9GS+cz8hDZahpvTrVCmySAEgKUL8oN4b2q1DluHrKtkqhAMWfH2s7kblhbM4Y4Y4SUnH2lt4drXKZ/4/Lot/2Q==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@xyflow/system@0.0.47':
resolution: {integrity: sha512-aUXJPIvsCFxGX70ccRG8LPsR+A8ExYXfh/noYNpqn8udKerrLdSHxMG2VsvUrQ1PGex10fOpbJwFU4A+I/Xv8w==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@@ -5878,6 +5942,9 @@ packages:
cjs-module-lexer@1.4.1:
resolution: {integrity: sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
@@ -6063,6 +6130,44 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -10027,6 +10132,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.2.2:
resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==}
peerDependencies:
react: 19.0.0
utf-8-validate@6.0.5:
resolution: {integrity: sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA==}
engines: {node: '>=6.14.2'}
@@ -10282,6 +10392,21 @@ packages:
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
zustand@4.5.5:
resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: 19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -14383,6 +14508,27 @@ snapshots:
dependencies:
'@types/node': 22.5.4
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-selection@3.0.11': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.34
@@ -14961,6 +15107,27 @@ snapshots:
'@xtuc/long@4.2.2': {}
'@xyflow/react@12.3.6(@types/react@19.0.1)(immer@9.0.21)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@xyflow/system': 0.0.47
classcat: 5.0.5
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
zustand: 4.5.5(@types/react@19.0.1)(immer@9.0.21)(react@19.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@xyflow/system@0.0.47':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
abab@2.0.6: {}
abbrev@2.0.0: {}
@@ -15492,6 +15659,8 @@ snapshots:
cjs-module-lexer@1.4.1: {}
classcat@5.0.5: {}
classnames@2.5.1: {}
clean-stack@2.2.0: {}
@@ -15673,6 +15842,42 @@ snapshots:
csstype@3.1.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {}
@@ -20216,6 +20421,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.1
use-sync-external-store@1.2.2(react@19.0.0):
dependencies:
react: 19.0.0
utf-8-validate@6.0.5:
dependencies:
node-gyp-build: 4.8.2
@@ -20476,4 +20685,12 @@ snapshots:
zod@3.23.8: {}
zustand@4.5.5(@types/react@19.0.1)(immer@9.0.21)(react@19.0.0):
dependencies:
use-sync-external-store: 1.2.2(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.1
immer: 9.0.21
react: 19.0.0
zwitch@2.0.4: {}

View File

@@ -44,6 +44,7 @@
"@payloadcms/plugin-search": "workspace:*",
"@payloadcms/plugin-sentry": "workspace:*",
"@payloadcms/plugin-seo": "workspace:*",
"@payloadcms/plugin-visual-jobs": "workspace:*",
"@payloadcms/plugin-stripe": "workspace:*",
"@payloadcms/richtext-lexical": "workspace:*",
"@payloadcms/richtext-slate": "workspace:*",

View File

@@ -0,0 +1,35 @@
import { Button } from '@payloadcms/ui'
import { type CustomComponent, getPayload, type PayloadServerReactComponent } from 'payload'
import React from 'react'
import config from './config.js'
async function handleClick() {
'use server'
const payload = await getPayload({ config })
await payload.jobs.queue({
input: {},
queue: 'default',
workflow: 'randomRetries',
})
let hasJobsRemaining = true
while (hasJobsRemaining) {
const response = await payload.jobs.run()
if (response.noJobsRemaining) {
hasJobsRemaining = false
}
}
}
export const QueueDemoJobs: PayloadServerReactComponent<CustomComponent> = ({ payload }) => {
return (
<div>
<Button onClick={handleClick}>Queue and run demo jobs</Button>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import type { TaskConfig, WorkflowConfig } from 'payload'
import { visualJobsPlugin } from '@payloadcms/plugin-visual-jobs'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { fileURLToPath } from 'node:url'
import path from 'path'
@@ -13,6 +14,7 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
plugins: [visualJobsPlugin({})],
collections: [
{
slug: 'posts',
@@ -76,6 +78,9 @@ export default buildConfigWithDefaults({
email: devUser.email,
password: devUser.password,
},
components: {
beforeDashboard: ['./QueueDemoJobs#QueueDemoJobs'],
},
},
jobs: {
jobsCollectionOverrides: ({ defaultJobsCollection }) => {
@@ -87,6 +92,7 @@ export default buildConfigWithDefaults({
},
}
},
deleteJobOnComplete: false,
tasks: [
{
retries: 2,
@@ -846,6 +852,106 @@ export default buildConfigWithDefaults({
})
},
} as WorkflowConfig<'retriesBackoffTest'>,
{
slug: 'randomRetries',
retries: 200,
handler: async ({ job, inlineTask, req }) => {
const { customerData } = await inlineTask('Fetch Customer Data', {
task: ({ req }) => {
// 20% chance to fail
if (Math.random() < 0.2) {
throw new Error('Failed on purpose')
}
return {
output: {
customerData: 'test',
},
}
},
retries: {
attempts: 40,
},
})
await inlineTask('Analyze Segments', {
task: ({ req }) => {
// 20% chance to fail
if (Math.random() < 0.4) {
throw new Error('Failed on purpose')
}
return {
output: {
segments: ['test1', 'test2'],
},
}
},
retries: {
attempts: 40,
},
input: {
customerData,
},
})
await inlineTask('Generate Copy', {
task: ({ req }) => {
// 20% chance to fail
if (Math.random() < 0.6) {
throw new Error('Failed on purpose')
}
return {
output: {},
}
},
retries: {
attempts: 40,
},
})
await inlineTask('Create Images', {
task: ({ req }) => {
// 20% chance to fail
if (Math.random() < 0.8) {
throw new Error('Failed on purpose')
}
return {
output: {},
}
},
retries: {
attempts: 40,
},
})
await inlineTask('Schedule Posts', {
task: ({ req }) => {
// 20% chance to fail
if (Math.random() < 0.8) {
throw new Error('Failed on purpose')
}
return {
output: {},
}
},
retries: {
attempts: 40,
},
})
await inlineTask('Track Metrics', {
task: ({ req }) => {
// 20% chance to fail
if (Math.random() < 0.9) {
throw new Error('Failed on purpose')
}
return {
output: {},
}
},
retries: {
attempts: 80,
},
})
},
} as WorkflowConfig<'randomRetries'>,
],
},
editor: lexicalEditor(),

View File

@@ -66,6 +66,7 @@ export interface Config {
inlineTaskTest: WorkflowInlineTaskTest;
externalWorkflow: WorkflowExternalWorkflow;
retriesBackoffTest: WorkflowRetriesBackoffTest;
randomRetries: WorkflowRandomRetries;
};
};
}
@@ -237,6 +238,7 @@ export interface PayloadJob {
| 'inlineTaskTest'
| 'externalWorkflow'
| 'retriesBackoffTest'
| 'randomRetries'
)
| null;
taskSlug?:
@@ -629,6 +631,13 @@ export interface WorkflowRetriesBackoffTest {
message: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "WorkflowRandomRetries".
*/
export interface WorkflowRandomRetries {
input?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@@ -28,6 +28,7 @@ export const tgzToPkgNameMap = {
'@payloadcms/plugin-search': 'payloadcms-plugin-search-*',
'@payloadcms/plugin-sentry': 'payloadcms-plugin-sentry-*',
'@payloadcms/plugin-seo': 'payloadcms-plugin-seo-*',
'@payloadcms/plugin-visual-jobs': 'payloadcms-plugin-visual-jobs-*',
'@payloadcms/plugin-stripe': 'payloadcms-plugin-stripe-*',
'@payloadcms/richtext-lexical': 'payloadcms-richtext-lexical-*',
'@payloadcms/richtext-slate': 'payloadcms-richtext-slate-*',

View File

@@ -78,6 +78,12 @@
"@payloadcms/plugin-seo/client": [
"./packages/plugin-seo/src/exports/client.ts"
],
"@payloadcms/plugin-visual-jobs/client": [
"./packages/plugin-visual-jobs/src/exports/client.ts"
],
"@payloadcms/plugin-visual-jobs/rsc": [
"./packages/plugin-visual-jobs/src/exports/rsc.ts"
],
"@payloadcms/plugin-sentry/client": [
"./packages/plugin-sentry/src/exports/client.ts"
],
@@ -154,6 +160,9 @@
{
"path": "./packages/plugin-seo"
},
{
"path": "./packages/plugin-visual-jobs"
},
{
"path": "./packages/plugin-stripe"
},