FEAT: NEW WORKFLOW ENGINE (#3160)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Yeuoly <admin@srmxy.cn>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Co-authored-by: jyong <jyong@dify.ai>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: jyong <718720800@qq.com>
This commit is contained in:
takatost
2024-04-08 18:51:46 +08:00
committed by GitHub
parent 2fb9850af5
commit 7753ba2d37
1161 changed files with 103836 additions and 10327 deletions

View File

@@ -0,0 +1,26 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Run from '@/app/components/workflow/run'
import { XClose } from '@/app/components/base/icons/src/vender/line/general'
type ILogDetail = {
runID: string
onClose: () => void
}
const DetailPanel: FC<ILogDetail> = ({ runID, onClose }) => {
const { t } = useTranslation()
return (
<div className='grow relative flex flex-col py-3'>
<span className='absolute right-3 top-4 p-1 cursor-pointer z-20' onClick={onClose}>
<XClose className='w-4 h-4 text-gray-500' />
</span>
<h1 className='shrink-0 px-4 py-1 text-md font-semibold text-gray-900'>{t('appLog.runDetail.workflowTitle')}</h1>
<Run runID={runID}/>
</div>
)
}
export default DetailPanel

View File

@@ -0,0 +1,55 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
MagnifyingGlassIcon,
} from '@heroicons/react/24/solid'
import type { QueryParam } from './index'
import { SimpleSelect } from '@/app/components/base/select'
type IFilterProps = {
queryParams: QueryParam
setQueryParams: (v: QueryParam) => void
}
const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps) => {
const { t } = useTranslation()
return (
<div className='flex flex-row flex-wrap gap-y-2 gap-x-4 items-center mb-4 text-gray-900 text-base'>
<div className="relative rounded-md">
<SimpleSelect
defaultValue={'all'}
className='!min-w-[100px]'
onSelect={
(item) => {
setQueryParams({ ...queryParams, status: item.value as string })
}
}
items={[{ value: 'all', name: 'All' },
{ value: 'succeeded', name: 'Success' },
{ value: 'failed', name: 'Fail' },
{ value: 'stopped', name: 'Stop' },
]}
/>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="query"
className="block w-[240px] bg-gray-100 shadow-sm rounded-md border-0 py-1.5 pl-10 text-gray-900 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-gray-200 focus-visible:outline-none sm:text-sm sm:leading-6"
placeholder={t('common.operation.search')!}
value={queryParams.keyword}
onChange={(e) => {
setQueryParams({ ...queryParams, keyword: e.target.value })
}}
/>
</div>
</div>
)
}
export default Filter

View File

@@ -0,0 +1,125 @@
'use client'
import type { FC, SVGProps } from 'react'
import React, { useState } from 'react'
import useSWR from 'swr'
import { usePathname } from 'next/navigation'
import { Pagination } from 'react-headless-pagination'
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
import { Trans, useTranslation } from 'react-i18next'
import Link from 'next/link'
import List from './list'
import Filter from './filter'
import s from './style.module.css'
import Loading from '@/app/components/base/loading'
import { fetchWorkflowLogs } from '@/service/log'
import { APP_PAGE_LIMIT } from '@/config'
import type { App, AppMode } from '@/types/app'
export type ILogsProps = {
appDetail: App
}
export type QueryParam = {
status?: string
keyword?: string
}
const ThreeDotsIcon = ({ className }: SVGProps<SVGElement>) => {
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
<path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
const EmptyElement: FC<{ appUrl: string }> = ({ appUrl }) => {
const { t } = useTranslation()
const pathname = usePathname()
const pathSegments = pathname.split('/')
pathSegments.pop()
return <div className='flex items-center justify-center h-full'>
<div className='bg-gray-50 w-[560px] h-fit box-border px-5 py-4 rounded-2xl'>
<span className='text-gray-700 font-semibold'>{t('appLog.table.empty.element.title')}<ThreeDotsIcon className='inline relative -top-3 -left-1.5' /></span>
<div className='mt-2 text-gray-500 text-sm font-normal'>
<Trans
i18nKey="appLog.table.empty.element.content"
components={{ shareLink: <Link href={`${pathSegments.join('/')}/overview`} className='text-primary-600' />, testLink: <Link href={appUrl} className='text-primary-600' target='_blank' rel='noopener noreferrer' /> }}
/>
</div>
</div>
</div>
}
const Logs: FC<ILogsProps> = ({ appDetail }) => {
const { t } = useTranslation()
const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all' })
const [currPage, setCurrPage] = React.useState<number>(0)
const query = {
page: currPage + 1,
limit: APP_PAGE_LIMIT,
...(queryParams.status !== 'all' ? { status: queryParams.status } : {}),
...(queryParams.keyword ? { keyword: queryParams.keyword } : {}),
}
const getWebAppType = (appType: AppMode) => {
if (appType !== 'completion' && appType !== 'workflow')
return 'chat'
return appType
}
const { data: workflowLogs, mutate } = useSWR({
url: `/apps/${appDetail.id}/workflow-app-logs`,
params: query,
}, fetchWorkflowLogs)
const total = workflowLogs?.total
return (
<div className='flex flex-col h-full'>
<h1 className='text-md font-semibold text-gray-900'>{t('appLog.workflowTitle')}</h1>
<p className='flex text-sm font-normal text-gray-500'>{t('appLog.workflowSubtitle')}</p>
<div className='flex flex-col py-4 flex-1'>
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
{/* workflow log */}
{total === undefined
? <Loading type='app' />
: total > 0
? <List logs={workflowLogs} appDetail={appDetail} onRefresh={mutate} />
: <EmptyElement appUrl={`${appDetail.site.app_base_url}/${getWebAppType(appDetail.mode)}/${appDetail.site.access_token}`} />
}
{/* Show Pagination only if the total is more than the limit */}
{(total && total > APP_PAGE_LIMIT)
? <Pagination
className="flex items-center w-full h-10 text-sm select-none mt-8"
currentPage={currPage}
edgePageCount={2}
middlePagesSiblingCount={1}
setCurrentPage={setCurrPage}
totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
truncableClassName="w-8 px-0.5 text-center"
truncableText="..."
>
<Pagination.PrevButton
disabled={currPage === 0}
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
<ArrowLeftIcon className="mr-3 h-3 w-3" />
{t('appLog.table.pagination.previous')}
</Pagination.PrevButton>
<div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
<Pagination.PageButton
activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
inactiveClassName="text-gray-500"
/>
</div>
<Pagination.NextButton
disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
{t('appLog.table.pagination.next')}
<ArrowRightIcon className="ml-3 h-3 w-3" />
</Pagination.NextButton>
</Pagination>
: null}
</div>
</div>
)
}
export default Logs

View File

@@ -0,0 +1,133 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import s from './style.module.css'
import DetailPanel from './detail'
import type { WorkflowAppLogDetail, WorkflowLogsResponse } from '@/models/log'
import type { App } from '@/types/app'
import Loading from '@/app/components/base/loading'
import Drawer from '@/app/components/base/drawer'
import Indicator from '@/app/components/header/indicator'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
type ILogs = {
logs?: WorkflowLogsResponse
appDetail?: App
onRefresh: () => void
}
const defaultValue = 'N/A'
const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [showDrawer, setShowDrawer] = useState<boolean>(false)
const [currentLog, setCurrentLog] = useState<WorkflowAppLogDetail | undefined>()
const statusTdRender = (status: string) => {
if (status === 'succeeded') {
return (
<div className='inline-flex items-center gap-1'>
<Indicator color={'green'} />
<span>Success</span>
</div>
)
}
if (status === 'failed') {
return (
<div className='inline-flex items-center gap-1'>
<Indicator color={'red'} />
<span className='text-red-600'>Fail</span>
</div>
)
}
if (status === 'stopped') {
return (
<div className='inline-flex items-center gap-1'>
<Indicator color={'yellow'} />
<span>Stop</span>
</div>
)
}
if (status === 'running') {
return (
<div className='inline-flex items-center gap-1'>
<Indicator color={'blue'} />
<span className='text-primary-600'>Running</span>
</div>
)
}
}
const onCloseDrawer = () => {
onRefresh()
setShowDrawer(false)
setCurrentLog(undefined)
}
if (!logs || !appDetail)
return <Loading />
return (
<div className='overflow-x-auto'>
<table className={`w-full min-w-[440px] border-collapse border-0 text-sm mt-3 ${s.logTable}`}>
<thead className="h-8 !pl-3 py-2 leading-[18px] border-b border-gray-200 text-xs text-gray-500 font-medium">
<tr>
<td className='w-[1.375rem] whitespace-nowrap'></td>
<td className='whitespace-nowrap'>{t('appLog.table.header.startTime')}</td>
<td className='whitespace-nowrap'>{t('appLog.table.header.status')}</td>
<td className='whitespace-nowrap'>{t('appLog.table.header.runtime')}</td>
<td className='whitespace-nowrap'>{t('appLog.table.header.tokens')}</td>
<td className='whitespace-nowrap'>{t('appLog.table.header.user')}</td>
{/* <td className='whitespace-nowrap'>{t('appLog.table.header.version')}</td> */}
</tr>
</thead>
<tbody className="text-gray-700 text-[13px]">
{logs.data.map((log: WorkflowAppLogDetail) => {
const endUser = log.created_by_end_user ? log.created_by_end_user.session_id : defaultValue
return <tr
key={log.id}
className={`border-b border-gray-200 h-8 hover:bg-gray-50 cursor-pointer ${currentLog?.id !== log.id ? '' : 'bg-gray-50'}`}
onClick={() => {
setCurrentLog(log)
setShowDrawer(true)
}}>
<td className='text-center align-middle'>{!log.read_at && <span className='inline-block bg-[#3F83F8] h-1.5 w-1.5 rounded'></span>}</td>
<td className='w-[160px]'>{dayjs.unix(log.created_at).format(t('appLog.dateTimeFormat') as string)}</td>
<td>{statusTdRender(log.workflow_run.status)}</td>
<td>
<div className={cn(
log.workflow_run.elapsed_time === 0 && 'text-gray-400',
)}>{`${log.workflow_run.elapsed_time.toFixed(3)}s`}</div>
</td>
<td>{log.workflow_run.total_tokens}</td>
<td>
<div className={cn(endUser === defaultValue ? 'text-gray-400' : 'text-gray-700', 'text-sm overflow-hidden text-ellipsis whitespace-nowrap')}>
{endUser}
</div>
</td>
{/* <td>VERSION</td> */}
</tr>
})}
</tbody>
</table>
<Drawer
isOpen={showDrawer}
onClose={onCloseDrawer}
mask={isMobile}
footer={null}
panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-gray-200'
>
<DetailPanel onClose={onCloseDrawer} runID={currentLog?.workflow_run.id || ''} />
</Drawer>
</div>
)
}
export default WorkflowAppLogList

View File

@@ -0,0 +1,9 @@
.logTable td {
padding: 7px 8px;
box-sizing: border-box;
max-width: 200px;
}
.pagination li {
list-style: none;
}