feat(web): add evaluation cell in workflow log

This commit is contained in:
JzoNg
2026-04-13 17:22:04 +08:00
parent 8c6dda125f
commit 46bc76bae3
8 changed files with 241 additions and 21 deletions

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EvaluationCell from '../evaluation-cell'
describe('EvaluationCell', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render a placeholder when evaluation data is empty', () => {
render(<EvaluationCell evaluation={[]} />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a trigger button when evaluation data is available', () => {
render(
<EvaluationCell
evaluation={[{
name: 'Faithfulness',
value: 0.98,
}]}
/>,
)
expect(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should render evaluation details when clicking the trigger', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'Faithfulness',
value: 0.98,
nodeInfo: {
node_id: 'node-1',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument()
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('0.98')).toBeInTheDocument()
expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument()
})
it('should render boolean values using readable text', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'Correctness',
value: true,
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByText('True')).toBeInTheDocument()
})
})
})

View File

@@ -215,6 +215,7 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
},
created_at: Date.now(),
...overrides,
evaluation: overrides.evaluation ?? [],
})
const createMockLogsResponse = (

View File

@@ -146,6 +146,7 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
email: 'test@example.com',
},
created_at: Date.now(),
evaluation: [],
...overrides,
})
@@ -216,6 +217,7 @@ describe('WorkflowAppLogList', () => {
expect(screen.getByText('appLog.table.header.runtime')).toBeInTheDocument()
expect(screen.getByText('appLog.table.header.tokens')).toBeInTheDocument()
expect(screen.getByText('appLog.table.header.user')).toBeInTheDocument()
expect(screen.getByText('appLog.table.header.evaluation')).toBeInTheDocument()
})
it('should render trigger column for workflow apps', () => {
@@ -404,8 +406,9 @@ describe('WorkflowAppLogList', () => {
// Arrow should rotate (indicated by class change)
// The sort icon should have rotate-180 class for ascending
const sortIcon = startTimeHeader.closest('div')?.querySelector('svg')
const sortIcon = startTimeHeader.closest('div')?.querySelector('.i-heroicons-arrow-down')
expect(sortIcon).toBeInTheDocument()
expect(sortIcon).toHaveClass('rotate-180')
})
it('should render sort arrow icon', () => {
@@ -416,7 +419,7 @@ describe('WorkflowAppLogList', () => {
)
// Check for ArrowDownIcon presence
const sortArrow = container.querySelector('svg.ml-0\\.5')
const sortArrow = container.querySelector('.i-heroicons-arrow-down')
expect(sortArrow).toBeInTheDocument()
})
})
@@ -491,6 +494,34 @@ describe('WorkflowAppLogList', () => {
// The row should have the selected class
expect(dataRow).toHaveClass('bg-background-default-hover')
})
it('should open evaluation popover without opening drawer when clicking evaluation trigger', async () => {
const user = userEvent.setup()
const logs = createMockLogsResponse([
createMockWorkflowLog({
evaluation: [{
name: 'Faithfulness',
value: 0.98,
nodeInfo: {
node_id: 'node-1',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
}],
}),
])
render(
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument()
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'appLog.runDetail.workflowTitle' })).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------

View File

@@ -0,0 +1,100 @@
'use client'
import type { EvaluationLogItem } from '@/models/log'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { getNodeVisual, getToneClasses } from '@/app/components/evaluation/components/metric-selector/utils'
import { cn } from '@/utils/classnames'
type EvaluationCellProps = {
evaluation: EvaluationLogItem[]
}
const formatEvaluationValue = (value: EvaluationLogItem['value']) => {
if (typeof value === 'boolean')
return value ? 'True' : 'False'
return String(value)
}
const EvaluationCell = ({
evaluation,
}: EvaluationCellProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
if (!evaluation.length) {
return (
<div className="flex items-center justify-center px-2 py-3 system-sm-regular text-text-quaternary">
-
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
aria-label={t('table.header.evaluation', { ns: 'appLog' })}
data-testid="workflow-log-evaluation-trigger"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary transition-colors',
'hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<span aria-hidden="true" className="i-ri-eye-line h-4 w-4" />
</button>
)}
/>
<PopoverContent
placement="left-start"
sideOffset={12}
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<div data-testid="workflow-log-evaluation-popover" className="max-h-[320px] overflow-y-auto bg-components-panel-bg">
{evaluation.map((item, index) => {
const nodeVisual = item.nodeInfo ? getNodeVisual(item.nodeInfo) : null
const nodeToneClasses = nodeVisual ? getToneClasses(nodeVisual.tone) : null
return (
<div
key={`${item.name}-${index}`}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3',
index !== evaluation.length - 1 && 'border-b border-divider-subtle',
)}
>
<div className="min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{item.name}</div>
{item.nodeInfo && nodeVisual && nodeToneClasses && (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<span className="truncate system-xs-regular text-text-tertiary">
{item.nodeInfo.title}
</span>
</div>
)}
</div>
<div className="max-w-[120px] text-right system-sm-regular wrap-break-word text-text-secondary">
{formatEvaluationValue(item.value)}
</div>
</div>
)
})}
</div>
</PopoverContent>
</Popover>
)
}
export default EvaluationCell

View File

@@ -2,8 +2,6 @@
import type { FC } from 'react'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFrom } from '@/models/log'
import type { App } from '@/types/app'
import { ArrowDownIcon } from '@heroicons/react/24/outline'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Drawer from '@/app/components/base/drawer'
@@ -14,6 +12,7 @@ import useTimestamp from '@/hooks/use-timestamp'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import DetailPanel from './detail'
import EvaluationCell from './evaluation-cell'
import TriggerByDisplay from './trigger-by-display'
type ILogs = {
@@ -59,7 +58,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
const statusTdRender = (status: string) => {
if (status === 'succeeded') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="green" />
<span className="text-util-colors-green-green-600">Success</span>
</div>
@@ -67,7 +66,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}
if (status === 'failed') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="red" />
<span className="text-util-colors-red-red-600">Failure</span>
</div>
@@ -75,7 +74,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}
if (status === 'stopped') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="yellow" />
<span className="text-util-colors-warning-warning-600">Stop</span>
</div>
@@ -83,7 +82,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}
if (status === 'paused') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="yellow" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
@@ -91,7 +90,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}
if (status === 'running') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="blue" />
<span className="text-util-colors-blue-light-blue-light-600">Running</span>
</div>
@@ -99,7 +98,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}
if (status === 'partial-succeeded') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="green" />
<span className="text-util-colors-green-green-600">Partial Success</span>
</div>
@@ -118,23 +117,22 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
return (
<div className="overflow-x-auto">
<table className={cn('mt-2 w-full min-w-[440px] border-collapse border-0')}>
<table className={cn('mt-2 w-full min-w-[560px] border-collapse border-0')}>
<thead className="system-xs-medium-uppercase text-text-tertiary">
<tr>
<td className="w-5 whitespace-nowrap rounded-l-lg bg-background-section-burn pl-2 pr-1"></td>
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">
<td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap"></td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">
<div className="flex cursor-pointer items-center hover:text-text-secondary" onClick={handleSort}>
{t('table.header.startTime', { ns: 'appLog' })}
<ArrowDownIcon
className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')}
/>
<span className={cn('i-heroicons-arrow-down', 'ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')} />
</div>
</td>
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">{t('table.header.status', { ns: 'appLog' })}</td>
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">{t('table.header.runtime', { ns: 'appLog' })}</td>
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">{t('table.header.tokens', { ns: 'appLog' })}</td>
<td className={cn('whitespace-nowrap bg-background-section-burn py-1.5 pl-3', !isWorkflow ? 'rounded-r-lg' : '')}>{t('table.header.user', { ns: 'appLog' })}</td>
{isWorkflow && <td className="whitespace-nowrap rounded-r-lg bg-background-section-burn py-1.5 pl-3">{t('table.header.triggered_from', { ns: 'appLog' })}</td>}
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.status', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.runtime', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.tokens', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.user', { ns: 'appLog' })}</td>
<td className={cn('bg-background-section-burn py-1.5 px-3 whitespace-nowrap text-center', !isWorkflow ? 'rounded-r-lg' : '')}>{t('table.header.evaluation', { ns: 'appLog' })}</td>
{isWorkflow && <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.triggered_from', { ns: 'appLog' })}</td>}
</tr>
</thead>
<tbody className="system-sm-regular text-text-secondary">
@@ -172,6 +170,9 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
{endUser}
</div>
</td>
<td className="p-2 pr-2" onClick={event => event.stopPropagation()}>
<EvaluationCell evaluation={log.evaluation} />
</td>
{isWorkflow && (
<td className="p-3 pr-2">
<TriggerByDisplay triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom} triggerMetadata={log.details?.trigger_metadata} />

View File

@@ -54,6 +54,7 @@
"table.empty.noOutput": "No output",
"table.header.adminRate": "Op. Rate",
"table.header.endUser": "End User or Account",
"table.header.evaluation": "EVALUATION",
"table.header.input": "Input",
"table.header.messageCount": "Message Count",
"table.header.output": "Output",

View File

@@ -54,6 +54,7 @@
"table.empty.noOutput": "无输出",
"table.header.adminRate": "管理员反馈",
"table.header.endUser": "用户或账户",
"table.header.evaluation": "评测",
"table.header.input": "输入",
"table.header.messageCount": "消息数",
"table.header.output": "输出",

View File

@@ -257,6 +257,15 @@ export type EndUserInfo = {
is_anonymous: boolean
session_id: string
}
export type EvaluationLogItem = {
name: string
value: string | number | boolean
nodeInfo?: {
node_id: string
type: string
title: string
}
}
export type WorkflowAppLogDetail = {
id: string
workflow_run: WorkflowRunDetail
@@ -267,6 +276,7 @@ export type WorkflowAppLogDetail = {
created_by_end_user?: EndUserInfo
created_at: number
read_at?: number
evaluation: EvaluationLogItem[]
}
export type WorkflowLogsResponse = {
data: Array<WorkflowAppLogDetail>