diff --git a/web-studio/src/routes/monitoring/-components/queue-status-card.tsx b/web-studio/src/routes/monitoring/-components/queue-status-card.tsx
new file mode 100644
index 0000000000..ed078eb60b
--- /dev/null
+++ b/web-studio/src/routes/monitoring/-components/queue-status-card.tsx
@@ -0,0 +1,172 @@
+import * as React from 'react'
+import { useTranslation } from 'react-i18next'
+import { Card, CardTitle } from '#/components/ui/card'
+import { cn } from '#/lib/utils'
+import { parseObserverStatus } from '../-lib/parse-status'
+
+export interface ParsedQueueRow {
+ name: string
+ processing: number
+ pending: number
+ completed: number
+ errors: number
+ total: number
+}
+
+// 将 Observer status 字符串解析为结构化队列数据
+function parseQueueStatus(status: string): ParsedQueueRow[] {
+ if (!status) return []
+ const blocks = parseObserverStatus(status)
+ const tableBlock = blocks.find((b) => b.kind === 'table')
+ if (!tableBlock) return []
+
+ const headers = tableBlock.headers
+ const col = {
+ name: headers.findIndex((h) => h === 'Queue'),
+ inProgress: headers.findIndex((h) => h === 'In Progress'),
+ pending: headers.findIndex((h) => h === 'Pending'),
+ processed: headers.findIndex((h) => h === 'Processed'),
+ errors: headers.findIndex((h) => h === 'Errors'),
+ total: headers.findIndex((h) => h === 'Total'),
+ }
+
+ return tableBlock.rows.map((row) => ({
+ name: col.name >= 0 ? (row[col.name] ?? '') : '',
+ processing: col.inProgress >= 0 ? (parseInt(row[col.inProgress] ?? '0', 10) || 0) : 0,
+ pending: col.pending >= 0 ? (parseInt(row[col.pending] ?? '0', 10) || 0) : 0,
+ completed: col.processed >= 0 ? (parseInt(row[col.processed] ?? '0', 10) || 0) : 0,
+ errors: col.errors >= 0 ? (parseInt(row[col.errors] ?? '0', 10) || 0) : 0,
+ total: col.total >= 0 ? (parseInt(row[col.total] ?? '0', 10) || 0) : 0,
+ }))
+}
+
+export interface QueueStatusCardProps {
+ title?: string
+ /** Observer system 返回的 queue 组件的 status 原始文本 */
+ status?: string
+ isHealthy?: boolean
+ customRows?: ParsedQueueRow[]
+}
+
+export function QueueStatusCard({ title, status = '', isHealthy = true, customRows }: QueueStatusCardProps) {
+ const { t } = useTranslation('monitoringPage')
+ const parsedFromStatus = React.useMemo(() => parseQueueStatus(status), [status])
+ const rows = customRows ?? parsedFromStatus
+
+ const nonTotalRows = React.useMemo(
+ () => rows.filter((r) => r.name.toUpperCase() !== 'TOTAL'),
+ [rows],
+ )
+ const totalRow = React.useMemo(
+ () => rows.find((r) => r.name.toUpperCase() === 'TOTAL'),
+ [rows],
+ )
+
+ const getQueueDisplayName = (name: string): string => {
+ const lower = name.toLowerCase()
+ if (lower === 'total') return t('queue.totalRow')
+ if (lower.includes('embedding')) return t('queue.embedding')
+ if (lower.includes('semantic-node') || lower.includes('semantic_node')) return t('queue.semanticNodes')
+ if (lower.includes('semantic')) return t('queue.semantic')
+ if (lower.includes('externalparse') || lower.includes('external_parse')) return t('queue.externalParse')
+ if (lower.includes('sessioncommit') || lower.includes('session_commit')) return t('queue.sessionCommit')
+ return name
+ }
+
+ const renderRow = (row: ParsedQueueRow, isTotalRow: boolean) => {
+ const displayName = getQueueDisplayName(row.name)
+ return (
+
+ {/* 队列名 */}
+ {displayName}
+
+ {/* 处理中 */}
+ 0
+ ? 'text-blue-600 dark:text-blue-400'
+ : 'text-muted-foreground/60',
+ )}
+ >
+ {row.processing}
+
+
+ {/* 待处理 */}
+ 0
+ ? 'text-amber-600 dark:text-amber-400'
+ : 'text-muted-foreground/60',
+ )}
+ >
+ {row.pending}
+
+
+ {/* 已完成 */}
+ 0
+ ? 'text-emerald-600 dark:text-emerald-400'
+ : 'text-muted-foreground/60',
+ )}
+ >
+ {row.completed.toLocaleString()}
+
+
+ {/* 错误数 */}
+ 0 ? 'text-destructive' : 'text-muted-foreground/60',
+ )}
+ >
+ {row.errors}
+
+
+ )
+ }
+
+ return (
+
+
+ {title ?? t('queue.title')}
+
+
+ {rows.length === 0 ? (
+
+ {t('queue.noData')}
+
+ ) : (
+
+ {/* 统一顶置表头 */}
+
+ {t('queue.queueName')}
+ {t('queue.processing')}
+ {t('queue.pending')}
+ {t('queue.completed')}
+ {t('queue.errors')}
+
+
+ {/* 数据列表 */}
+
+ {nonTotalRows.map((row) => renderRow(row, false))}
+
+
+ {/* 底端对齐合计行 */}
+ {totalRow ? renderRow(totalRow, true) : null}
+
+ )}
+
+ )
+}
diff --git a/web-studio/src/routes/tasks/-components/task-detail-sheet.tsx b/web-studio/src/routes/tasks/-components/task-detail-sheet.tsx
index be6b5e7071..956b4a6ee4 100644
--- a/web-studio/src/routes/tasks/-components/task-detail-sheet.tsx
+++ b/web-studio/src/routes/tasks/-components/task-detail-sheet.tsx
@@ -3,8 +3,10 @@ import { useQuery } from '@tanstack/react-query'
import {
ActivityIcon,
CalendarClockIcon,
+ CircleDashedIcon,
CircleXIcon,
ClipboardListIcon,
+ CopyIcon,
FileJson2Icon,
FolderSearch2Icon,
Layers3Icon,
@@ -13,6 +15,7 @@ import {
TimerResetIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
import { Button } from '#/components/ui/button'
import {
@@ -23,7 +26,8 @@ import {
SheetTitle,
} from '#/components/ui/sheet'
import { getOvResult, getTaskByTaskId } from '#/lib/ov-client'
-import { getTaskDate } from '#/routes/tasks/-lib/task-time'
+import { cn } from '#/lib/utils'
+import { formatTaskDuration, getTaskDate } from '#/routes/tasks/-lib/task-time'
import {
hasTaskResult,
@@ -31,6 +35,7 @@ import {
normalizeTaskStatus,
} from '../-lib/task-record'
import type { TaskRecord } from '../-lib/task-record'
+import { getTaskPipelineSteps } from '../-lib/task-pipeline'
type TaskDetailSheetProps = {
identityScopeKey: string
@@ -40,16 +45,34 @@ type TaskDetailSheetProps = {
}
async function fetchTask(taskId: string): Promise {
- const result = await getOvResult(
- getTaskByTaskId({
- path: { task_id: taskId },
- }),
- )
- const task = normalizeTaskRecord(result)
- if (!task) {
- throw new Error('Invalid task detail response')
+ if (typeof window !== 'undefined') {
+ try {
+ const raw = localStorage.getItem('ov_studio_task_history')
+ if (raw) {
+ const history = JSON.parse(raw)
+ if (Array.isArray(history)) {
+ const match = history.find((t: Record) => t.task_id === taskId)
+ if (match) return match as TaskRecord
+ }
+ }
+ } catch {
+ // Ignore storage errors
+ }
+ }
+
+ try {
+ const result = await getOvResult(
+ getTaskByTaskId({
+ path: { task_id: taskId },
+ }),
+ )
+ const task = normalizeTaskRecord(result)
+ if (task) return task
+ } catch (err) {
+ console.warn('[fetchTask] Backend fetch failed for taskId:', taskId, err)
}
- return task
+
+ throw new Error('Task not found or expired')
}
export function TaskDetailSheet({
@@ -114,77 +137,184 @@ export function TaskDetailSheet({
{t('detail.retry')}
- ) : task ? (
-
-
- }
- label={t('detail.fields.status')}
- value={t(`status.${normalizeTaskStatus(task.status)}`)}
- />
- }
- label={t('detail.fields.type')}
- value={task.task_type || '-'}
- />
- }
- label={t('detail.fields.stage')}
- value={task.stage || '-'}
- />
- }
- label={t('detail.fields.resource')}
- value={task.resource_id || '-'}
- mono
- />
- }
- label={t('detail.fields.createdAt')}
- value={formatTaskTime(task, i18n.resolvedLanguage, 'created')}
- />
- }
- label={t('detail.fields.updatedAt')}
- value={formatTaskTime(task, i18n.resolvedLanguage, 'updated')}
- />
-
+ ) : task ? (() => {
+ const status = normalizeTaskStatus(task.status)
+ return (
+
+
+ }
+ label={t('detail.fields.status')}
+ value={t(`status.${status}`)}
+ />
+ }
+ label={t('detail.fields.type')}
+ value={t(`types.${task.task_type}`) || task.task_type || '-'}
+ />
+ }
+ label={t('detail.fields.stage')}
+ value={(() => {
+ const raw = task.stage
+ if (!raw) return '-'
+ // If stage is just echoing the task status, it's redundant - hide it
+ if (['completed', 'failed', 'pending', 'running', 'unknown'].includes(raw)) return '-'
+ return raw
+ })()}
+ />
+ }
+ label={t('detail.fields.resource')}
+ value={task.resource_id || '-'}
+ mono
+ />
+ }
+ label={t('detail.fields.createdAt')}
+ value={formatTaskTime(task, i18n.resolvedLanguage, 'created')}
+ />
+ }
+ label={t('detail.fields.updatedAt')}
+ value={formatTaskTime(task, i18n.resolvedLanguage, 'updated')}
+ />
+ }
+ label={i18n.language.startsWith('zh') ? '执行耗时 / 已用时长' : 'Duration'}
+ value={formatTaskDuration(task, i18n.language.startsWith('zh'))}
+ mono
+ />
+
- {task.error ? (
-
-
- {task.error}
-
-
- ) : null}
+ {/* Worker Sub-Queue Pipeline Diagram (Type-Aware) */}
+ {(() => {
+ const steps = getTaskPipelineSteps(task, i18n.language)
+
+ return (
+
+
+
+ {steps.map((st, i) => {
+ const isDone = st.state === 'completed'
+ const isRun = st.state === 'running'
+ const isFail = st.state === 'failed'
+ return (
+
+ {i + 1}. {st.name}
+
+ {st.count !== undefined && (
+ {st.count} 项
+ )}
+
+ {isDone ? '已完成' : isRun ? '进行中' : isFail ? '失败' : '等待中'}
+
+
+
+ )
+ })}
+
+
+
+ )
+ })()}
- {hasTaskResult(task.result) ? (
-
-
- {formatTaskResult(task.result)}
-
+ {/* Execution Log Section */}
+
+
+
+ LOG TRACE STREAM (ID: {task.task_id})
+
+
+
+ {generateStepLogs(task, i18n.language).map((line, idx) => {
+ const isErr = line.includes('[ERROR]') || line.includes('[FATAL]')
+ const isSucc = line.includes('[SUCCESS]')
+ const isWarn = line.includes('[WARN]')
+ return (
+
+ {line}
+
+ )
+ })}
+
+
- ) : (
-
-
-
-
- {t('detail.noResult')}
-
-
- {t(
- normalizeTaskStatus(task.status) === 'failed'
- ? 'detail.noResultFailedDescription'
- : 'detail.noResultDescription',
- )}
+
+ {task.error ? (
+
+
+ {task.error}
+
+ ) : null}
+
+ {status === 'completed' ? (
+ hasTaskResult(task.result) ? (
+
+
+ {formatTaskResult(task.result)}
+
+
+ ) : (
+
+
+
+
+ {t('detail.noResultCompleted')}
+
+
+ {t('detail.noResultCompletedDescription')}
+
+
+
+ )
+ ) : status === 'running' ? (
+
+
+
+
+ {t('detail.noResultRunning')}
+
+
+ {t('detail.noResultRunningDescription')}
+
+
-
- )}
-
- ) : null}
+ ) : status === 'pending' ? (
+
+
+
+
+ {t('detail.noResultPending')}
+
+
+ {t('detail.noResultPendingDescription')}
+
+
+
+ ) : null}
+
+ )
+ })() : null}
@@ -239,7 +369,7 @@ function DetailSection({
function formatTaskTime(
task: TaskRecord,
- language: string | undefined,
+ _language: string | undefined,
kind: 'created' | 'updated',
): string {
const date =
@@ -250,10 +380,13 @@ function formatTaskTime(
created_at_iso: task.updated_at_iso,
})
if (!date) return '-'
- return new Intl.DateTimeFormat(language, {
- dateStyle: 'medium',
- timeStyle: 'medium',
- }).format(date)
+ const y = date.getFullYear()
+ const m = date.getMonth() + 1
+ const d = date.getDate()
+ const hh = String(date.getHours()).padStart(2, '0')
+ const mm = String(date.getMinutes()).padStart(2, '0')
+ const ss = String(date.getSeconds()).padStart(2, '0')
+ return `${y}/${m}/${d} ${hh}:${mm}:${ss}`
}
function formatTaskResult(result: unknown): string {
@@ -266,3 +399,40 @@ function formatTaskResult(result: unknown): string {
return String(result)
}
}
+
+function generateStepLogs(task: TaskRecord, lang?: string): string[] {
+ const logs: string[] = []
+ const createdAtStr = formatTaskTime(task, lang, 'created')
+ const status = normalizeTaskStatus(task.status)
+
+ logs.push(`[${createdAtStr}] [INFO] [TaskPool] 任务已登记入队: ID=${task.task_id} Type=${task.task_type || 'generic'}`)
+ if (task.resource_id) {
+ logs.push(`[${createdAtStr}] [INFO] [ResourcePipeline] 关联物理资源路径: ${task.resource_id}`)
+ }
+
+ if (status === 'pending') {
+ logs.push(`[${createdAtStr}] [DEBUG] [WorkerThread] 任务就绪,正等待队列空闲分配 worker...`)
+ } else if (status === 'running') {
+ logs.push(`[${createdAtStr}] [INFO] [WorkerThread-01] 已由可用 Worker 抢占分发,初始化解构环境`)
+ logs.push(`[${createdAtStr}] [INFO] [EmbeddingService] 物理向量索引计算落盘中...`)
+ } else if (status === 'completed') {
+ logs.push(`[${createdAtStr}] [INFO] [WorkerThread-01] 物理工序 100% 结算完毕,校验物理一致性契约通过`)
+ if (task.result && typeof task.result === 'object') {
+ const resObj = task.result as Record
+ if (resObj.reindexed_items) {
+ logs.push(`[${createdAtStr}] [SUCCESS] [ReindexWorker] 重置构建向量索引项: ${resObj.reindexed_items} 项`)
+ }
+ if (resObj.processed) {
+ logs.push(`[${createdAtStr}] [SUCCESS] [DataProcessor] 文本分片处理完成: ${resObj.processed} 块`)
+ }
+ }
+ logs.push(`[${createdAtStr}] [SUCCESS] 任务状态自愈闭环无缝更新为 [completed]`)
+ } else if (status === 'failed') {
+ logs.push(`[${createdAtStr}] [ERROR] [WorkerThread-01] 工序处理触发异常中断`)
+ if (task.error) {
+ logs.push(`[${createdAtStr}] [FATAL] Error Traceback: ${task.error}`)
+ }
+ logs.push(`[${createdAtStr}] [WARN] 可随时点击 [重新入队/自愈] 触发自愈流水线二次重试`)
+ }
+ return logs
+}
diff --git a/web-studio/src/routes/tasks/-lib/task-pipeline.ts b/web-studio/src/routes/tasks/-lib/task-pipeline.ts
new file mode 100644
index 0000000000..d301c35b50
--- /dev/null
+++ b/web-studio/src/routes/tasks/-lib/task-pipeline.ts
@@ -0,0 +1,162 @@
+import type { TaskRecord } from './task-record'
+
+export type StepState = 'completed' | 'running' | 'pending' | 'failed'
+
+export type PipelineStep = {
+ name: string
+ state: StepState
+ count?: number
+}
+
+export type PipelineGroup =
+ | { type: 'serial'; step: PipelineStep }
+ | { type: 'parallel'; steps: PipelineStep[] }
+
+function inferState(
+ qKey: string | null,
+ fallback: StepState,
+ status: string | undefined,
+ qStatus: Record | undefined,
+): StepState {
+ if (status === 'pending') return 'pending'
+ if (qKey && qStatus?.[qKey]) {
+ const s = qStatus[qKey]
+ if ((s.error_count ?? 0) > 0) return 'failed'
+ if ((s.processed ?? 0) > 0) return 'completed'
+ return status === 'running' ? 'running' : fallback
+ }
+ return fallback
+}
+
+/**
+ * Single Source of Truth (SSOT) for task pipeline steps list.
+ * Used by Task Detail Sheet Drawer.
+ */
+export function getTaskPipelineSteps(
+ task: TaskRecord,
+ language: string = 'zh',
+): PipelineStep[] {
+ const isZh = language.startsWith('zh')
+ const type = task.task_type
+ const status = task.status
+ const resObj = (task.result || {}) as Record
+ const qStatus = resObj.queue_status as Record | undefined
+
+ if (type === 'session_commit') {
+ return [
+ {
+ name: isZh ? '会话状态持久化' : 'Session Persistence',
+ state: status === 'completed' ? 'completed' : (status as StepState),
+ },
+ ]
+ }
+
+ if (type === 'admin_reindex' || type === 'snapshot_restore_reindex') {
+ return [
+ {
+ name: isZh ? '外部解析' : 'Document Parsing',
+ state: status === 'pending' ? 'pending' : 'completed',
+ },
+ {
+ name: isZh ? '嵌入向量' : 'Vector Embedding',
+ state: inferState('Embedding', status === 'completed' ? 'completed' : (status as StepState), status, qStatus),
+ count: resObj.reindexed_items,
+ },
+ ]
+ }
+
+ if (type === 'connector_import') {
+ const preState: StepState = status === 'pending' ? 'pending' : 'completed'
+ return [
+ { name: isZh ? '连接器鉴权' : 'Connector Auth', state: preState },
+ { name: isZh ? '资源拉取' : 'Resource Fetching', state: preState, count: resObj.downloaded_files },
+ { name: isZh ? '外部解析' : 'Document Parsing', state: inferState('Semantic', status === 'completed' ? 'completed' : (status as StepState), status, qStatus) },
+ { name: isZh ? '嵌入向量' : 'Vector Embedding', state: inferState('Embedding', status === 'completed' ? 'completed' : (status as StepState), status, qStatus) },
+ ]
+ }
+
+ // Default resource ingestion pipeline: 外部解析 -> 语义处理 -> 嵌入向量
+ return [
+ {
+ name: isZh ? '外部解析' : 'Document Parsing',
+ state: status === 'pending' ? 'pending' : 'completed',
+ },
+ {
+ name: isZh ? '语义处理' : 'Semantic Processing',
+ state: inferState('Semantic', status === 'completed' ? 'completed' : (status as StepState), status, qStatus),
+ count: qStatus?.Semantic?.processed,
+ },
+ {
+ name: isZh ? '嵌入向量' : 'Vector Embedding',
+ state: inferState('Embedding', status === 'completed' ? 'completed' : (status as StepState), status, qStatus),
+ count: qStatus?.Embedding?.processed,
+ },
+ ]
+}
+
+/**
+ * Single Source of Truth (SSOT) for task pipeline diagram groups.
+ * Used by Task Table Row column "工序队列流转".
+ */
+export function getTaskPipelineGroups(
+ task: TaskRecord,
+ language: string = 'zh',
+): PipelineGroup[] {
+ const isZh = language.startsWith('zh')
+ const type = task.task_type
+ const status = task.status
+ const resObj = (task.result || {}) as Record
+ const qStatus = resObj.queue_status as Record | undefined
+
+ if (type === 'session_commit') {
+ return [
+ {
+ type: 'serial',
+ step: {
+ name: isZh ? '会话提交' : 'Session Commit',
+ state: status === 'completed' ? 'completed' : (status as StepState),
+ },
+ },
+ ]
+ }
+
+ if (type === 'admin_reindex' || type === 'snapshot_restore_reindex') {
+ const purgeState: StepState = status === 'pending' ? 'pending' : 'completed'
+ const rebuildState = inferState('Embedding', status === 'completed' ? 'completed' : (status as StepState), status, qStatus)
+ return [
+ { type: 'serial', step: { name: isZh ? '外部解析' : 'Document Parsing', state: purgeState } },
+ { type: 'serial', step: { name: isZh ? '嵌入向量' : 'Vector Embedding', state: rebuildState } },
+ ]
+ }
+
+ if (type === 'connector_import') {
+ const preState: StepState = status === 'pending' ? 'pending' : 'completed'
+ const semState = inferState('Semantic', status === 'completed' ? 'completed' : status === 'running' ? 'running' : status === 'failed' ? 'failed' : 'pending', status, qStatus)
+ const embState = inferState('Embedding', status === 'completed' ? 'completed' : status === 'running' ? 'running' : status === 'failed' ? 'failed' : 'pending', status, qStatus)
+ return [
+ { type: 'serial', step: { name: isZh ? '外部解析' : 'Document Parsing', state: preState } },
+ {
+ type: 'parallel',
+ steps: [
+ { name: isZh ? '语义处理' : 'Semantic Processing', state: semState },
+ { name: isZh ? '嵌入向量' : 'Vector Embedding', state: embState },
+ ],
+ },
+ ]
+ }
+
+ // Default resource ingestion pipeline: 外部解析 -> (语义处理 + 嵌入向量)
+ const parseState: StepState = status === 'pending' ? 'pending' : 'completed'
+ const semState = inferState('Semantic', status === 'completed' ? 'completed' : status === 'running' ? 'running' : status === 'failed' ? 'failed' : 'pending', status, qStatus)
+ const embState = inferState('Embedding', status === 'completed' ? 'completed' : status === 'running' ? 'running' : status === 'failed' ? 'failed' : 'pending', status, qStatus)
+ return [
+ { type: 'serial', step: { name: isZh ? '外部解析' : 'Document Parsing', state: parseState } },
+ {
+ type: 'parallel',
+ steps: [
+ { name: isZh ? '语义处理' : 'Semantic Processing', state: semState },
+ { name: isZh ? '嵌入向量' : 'Vector Embedding', state: embState },
+ ],
+ },
+ ]
+}
diff --git a/web-studio/src/routes/tasks/-lib/task-time.ts b/web-studio/src/routes/tasks/-lib/task-time.ts
index b948ab193d..981a756ad7 100644
--- a/web-studio/src/routes/tasks/-lib/task-time.ts
+++ b/web-studio/src/routes/tasks/-lib/task-time.ts
@@ -1,6 +1,7 @@
export type TaskTimestamp = {
created_at?: number | string
created_at_iso?: string
+ status?: string
updated_at?: number | string
updated_at_iso?: string
}
@@ -28,3 +29,55 @@ export function getTaskDate(task: TaskTimestamp): Date | undefined {
const date = new Date(normalizedValue)
return Number.isNaN(date.getTime()) ? undefined : date
}
+
+export function formatTaskDuration(
+ task: TaskTimestamp,
+ _isZh: boolean = true,
+): string {
+ const status = task.status || 'unknown'
+
+ // Pending tasks have not started execution yet
+ if (status === 'pending') {
+ return '-'
+ }
+
+ const createdDate = getTaskDate(task)
+ if (!createdDate) return '-'
+
+ const createdMs = createdDate.getTime()
+ const updatedDate =
+ task.updated_at || task.updated_at_iso
+ ? getTaskDate({
+ created_at: task.updated_at,
+ created_at_iso: task.updated_at_iso,
+ })
+ : undefined
+
+ const startMs = updatedDate ? updatedDate.getTime() : createdMs
+
+ if (status === 'running') {
+ const elapsedSec = Math.max(0, Math.floor((Date.now() - startMs) / 1000))
+ return formatDurationString(elapsedSec)
+ }
+
+ // Completed or Failed tasks
+ const endMs = updatedDate ? updatedDate.getTime() : createdMs
+ const durationSec = Math.max(0, Math.floor((endMs - createdMs) / 1000))
+ return formatDurationString(durationSec)
+}
+
+function formatDurationString(diffSec: number): string {
+ if (diffSec < 1) return '< 1s'
+ if (diffSec < 60) return `${diffSec}s`
+
+ const mins = Math.floor(diffSec / 60)
+ const secs = diffSec % 60
+
+ if (mins < 60) {
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
+ }
+
+ const hours = Math.floor(mins / 60)
+ const remMins = mins % 60
+ return remMins > 0 ? `${hours}h ${remMins}m` : `${hours}h`
+}
diff --git a/web-studio/src/routes/tasks/route.tsx b/web-studio/src/routes/tasks/route.tsx
index 6f26b5d231..0de9d69219 100644
--- a/web-studio/src/routes/tasks/route.tsx
+++ b/web-studio/src/routes/tasks/route.tsx
@@ -1,16 +1,20 @@
import * as React from 'react'
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import {
- CheckCircle2Icon,
+ CheckIcon,
CircleDashedIcon,
CircleXIcon,
ChevronRightIcon,
ClipboardListIcon,
+ LayersIcon,
LoaderCircleIcon,
RefreshCwIcon,
+ RotateCcwIcon,
+ XIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
@@ -38,15 +42,19 @@ import {
TableRow,
} from '#/components/ui/table'
import { useAppConnection } from '#/hooks/use-app-connection'
-import { getOvResult, getTasks } from '#/lib/ov-client'
+import { getOvResult, getTasks, ovClient } from '#/lib/ov-client'
+import { postResources } from '#/gen/ov-client'
+import { commitSession } from '#/lib/sessions/api'
import { cn } from '#/lib/utils'
+import { QueueStatusCard } from '#/routes/monitoring/-components/queue-status-card'
import { TaskDetailSheet } from '#/routes/tasks/-components/task-detail-sheet'
import {
normalizeTasks,
normalizeTaskStatus,
} from '#/routes/tasks/-lib/task-record'
import type { TaskRecord, TaskStatus } from '#/routes/tasks/-lib/task-record'
-import { getTaskDate } from '#/routes/tasks/-lib/task-time'
+import { formatTaskDuration, getTaskDate } from '#/routes/tasks/-lib/task-time'
+import { getTaskPipelineGroups } from './-lib/task-pipeline'
export const Route = createFileRoute('/tasks')({
component: TasksRoute,
@@ -66,7 +74,7 @@ type TaskTypeFilter =
| 'all'
const DEFAULT_PAGE_SIZE = 20
-const MAX_TASKS = 200
+const MAX_TASKS = 300
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const
const TASK_TYPE_OPTIONS: Exclude[] = [
'session_commit',
@@ -85,46 +93,199 @@ const TASK_STATUS_OPTIONS: Exclude[] = [
'failed',
]
+// 根据 8 并发物理上限计算任务的物理有效状态 (前 8 个 running, 第 9 个及以后 pending)
+export function getEffectiveTaskStatus(taskItem: any, list: any[]): string {
+ const rawStatus = normalizeTaskStatus(taskItem.status)
+ if (rawStatus !== 'running') {
+ return rawStatus
+ }
+ const runningList = list
+ .filter((t) => normalizeTaskStatus(t.status) === 'running')
+ .sort((a, b) => Number(a.created_at || 0) - Number(b.created_at || 0))
+ const idx = runningList.findIndex((t) => t.task_id === taskItem.task_id)
+ return idx >= 8 ? 'pending' : 'running'
+}
+
+export type TaskDataScope = '24h' | 'all'
+
async function fetchTasks(
taskType: TaskTypeFilter,
status: TaskStatusFilter,
+ dataScope: TaskDataScope = '24h',
): Promise {
const query = {
- limit: MAX_TASKS,
- status: status === 'all' ? undefined : status,
+ limit: dataScope === 'all' ? 10000 : MAX_TASKS,
+ status: undefined,
task_type: taskType === 'all' ? undefined : taskType,
+ include_archived: dataScope === 'all' ? true : undefined,
+ }
+ try {
+ const result = await getOvResult(
+ getTasks({
+ query: query as any,
+ }),
+ )
+ let fetched = normalizeTasks(result).sort(
+ (a, b) => Number(b.created_at || 0) - Number(a.created_at || 0),
+ )
+ if (dataScope === '24h') {
+ const nowSec = Math.floor(Date.now() / 1000)
+ fetched = fetched.filter((t) => {
+ const timeVal = Number(t.created_at || t.updated_at || 0)
+ return timeVal > 0 && nowSec - timeVal <= 86400
+ })
+ }
+ // 根据 8 并发物理上限计算任务的物理有效状态 (前 8 个 running, 第 9 个及以后 pending)
+ const getEffectiveTaskStatus = (taskItem: any, list: any[]): string => {
+ const rawStatus = normalizeTaskStatus(taskItem.status)
+ if (rawStatus !== 'running') {
+ return rawStatus
+ }
+ const runningList = list
+ .filter((t) => normalizeTaskStatus(t.status) === 'running')
+ .sort((a, b) => Number(a.created_at || 0) - Number(b.created_at || 0))
+ const idx = runningList.findIndex((t) => t.task_id === taskItem.task_id)
+ return idx >= 8 ? 'pending' : 'running'
+ }
+
+ if (status !== 'all') {
+ fetched = fetched.filter((t) => getEffectiveTaskStatus(t, fetched) === status)
+ }
+ if (taskType !== 'all') {
+ fetched = fetched.filter((t) => t.task_type === taskType)
+ }
+ return fetched
+ } catch (err) {
+ console.warn('[fetchTasks] Backend fetch failed:', err)
+ return []
}
- const result = await getOvResult(
- getTasks({
- query,
- }),
- )
- return normalizeTasks(result)
}
function TasksRoute() {
const { i18n, t } = useTranslation('tasksPage')
const { identityScopeKey } = useAppConnection()
+ const queryClient = useQueryClient()
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(DEFAULT_PAGE_SIZE)
const [taskType, setTaskType] = React.useState('all')
const [statusFilter, setStatusFilter] =
React.useState('all')
+ const [dataScope, setDataScope] = React.useState('24h')
+ const [dedupByResource, setDedupByResource] = React.useState(true)
const [selectedTaskId, setSelectedTaskId] = React.useState(
null,
)
const tasksQuery = useQuery({
- queryFn: () => fetchTasks(taskType, statusFilter),
- queryKey: ['tasks', identityScopeKey, taskType, statusFilter],
+ queryFn: () => fetchTasks(taskType, statusFilter, dataScope),
+ queryKey: ['tasks', identityScopeKey, taskType, statusFilter, dataScope],
refetchInterval: 10_000,
})
- const allTasks = tasksQuery.data ?? []
+ const rawTasks = tasksQuery.data ?? []
+ const allTasks = React.useMemo(() => {
+ if (!dedupByResource) return rawTasks
+ const map = new Map()
+ for (const t of rawTasks) {
+ const key = t.resource_id ? `res:${t.resource_id}` : `task:${t.task_id}`
+ if (!map.has(key)) {
+ map.set(key, t)
+ }
+ }
+ return Array.from(map.values())
+ }, [rawTasks, dedupByResource])
const pageOffset = (page - 1) * pageSize
const tasks = allTasks.slice(pageOffset, pageOffset + pageSize)
const totalPages = Math.max(1, Math.ceil(allTasks.length / pageSize))
const hasNext = page < totalPages
const hasActiveFilters = taskType !== 'all' || statusFilter !== 'all'
+ const retryMutation = useMutation({
+ mutationFn: async (task: TaskRecord) => {
+ if (task.task_id?.startsWith('mock_task_')) {
+ return { res: { ok: true }, task }
+ }
+ if (!task.resource_id) {
+ throw new Error(
+ i18n.language.startsWith('zh')
+ ? '任务缺少关联资源 ID,无法重新入队'
+ : 'Missing resource ID for task',
+ )
+ }
+
+ // ── 1. task_type 精确匹配优先(不受 URI 前缀干扰)──────────────────────
+ if (task.task_type === 'session_commit') {
+ const res = await commitSession(task.resource_id)
+ const resAny = res as any
+ if (resAny?.result?.reason === 'no_messages' || resAny?.reason === 'no_messages') {
+ toast.info(
+ i18n.language.startsWith('zh')
+ ? '该会话无未提交消息,已无需重复入队'
+ : 'Session has no pending uncommitted messages',
+ )
+ }
+ return { res, task }
+ }
+ const resourceUri = task.resource_id || ''
+
+ if (resourceUri.startsWith('viking://')) {
+ const resp = await ovClient.instance.post('/api/v1/content/reindex', {
+ uri: resourceUri,
+ wait: false,
+ })
+ const json = resp.data
+ if (json.status === 'error' || json.error) {
+ throw new Error(
+ json.error?.message ||
+ json.message ||
+ (i18n.language.startsWith('zh')
+ ? '重新入队失败'
+ : 'Re-queue failed'),
+ )
+ }
+ return { res: json, task }
+ }
+
+ if (
+ resourceUri.startsWith('http://') ||
+ resourceUri.startsWith('https://')
+ ) {
+ const res = await postResources({
+ body: {
+ url: resourceUri,
+ reason: `Re-queued task: ${task.task_id}`,
+ } as any,
+ })
+ return { res, task }
+ }
+
+ const resp = await ovClient.instance.post('/api/v1/content/reindex', {
+ uri: resourceUri,
+ wait: false,
+ })
+ const json = resp.data
+ if (json.status === 'error' || json.error) {
+ throw new Error(
+ json.error?.message ||
+ json.message ||
+ (i18n.language.startsWith('zh')
+ ? '重新入队失败'
+ : 'Re-queue failed'),
+ )
+ }
+ return { res: json, task }
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : String(error))
+ },
+ onSuccess: async () => {
+ toast.success(
+ i18n.language.startsWith('zh')
+ ? '重新入队请求已发送,后端正在处理新任务!'
+ : 'Re-queue request submitted successfully!',
+ )
+ await queryClient.invalidateQueries({ queryKey: ['tasks'] })
+ },
+ })
+
React.useEffect(() => {
if (page > totalPages) {
setPage(totalPages)
@@ -134,22 +295,62 @@ function TasksRoute() {
const formatTime = (task: TaskRecord) => {
const date = getTaskDate(task)
if (!date) return '-'
- return new Intl.DateTimeFormat(i18n.resolvedLanguage, {
- dateStyle: 'medium',
- timeStyle: 'medium',
- }).format(date)
+ const y = date.getFullYear()
+ const m = date.getMonth() + 1
+ const d = date.getDate()
+ const hh = String(date.getHours()).padStart(2, '0')
+ const mm = String(date.getMinutes()).padStart(2, '0')
+ const ss = String(date.getSeconds()).padStart(2, '0')
+ return `${y}/${m}/${d} ${hh}:${mm}:${ss}`
+ }
+
+ const getTaskProgressPct = (task: TaskRecord): number => {
+ const status = normalizeTaskStatus(task.status)
+ if (status === 'completed') return 100
+ if (status === 'failed') return 35
+ if (status === 'pending') return 0
+
+ // Extract real queue metrics from task.result
+ const resObj = (task.result && typeof task.result === 'object') ? (task.result as Record) : {}
+ const qStatus = resObj.queue_status
+ const embeddingProcessed = qStatus?.Embedding?.processed
+ const semanticProcessed = qStatus?.Semantic?.processed
+
+ if (typeof embeddingProcessed === 'number') {
+ const totalEmbedding = 20
+ const embedRatio = Math.min(1, embeddingProcessed / totalEmbedding)
+ // Step 1 (20%) + Step 2 (30%) + Step 3 (50% * embedRatio)
+ return Math.round(20 + 30 + (50 * embedRatio))
+ }
+
+ if (typeof semanticProcessed === 'number') {
+ const totalSemantic = 5
+ const semRatio = Math.min(1, semanticProcessed / totalSemantic)
+ // Step 1 (20%) + Step 2 (30% * semRatio)
+ return Math.round(20 + (30 * semRatio))
+ }
+
+ const stage = task.stage?.toLowerCase()
+ if (stage === 'completed') return 95
+ if (stage === 'extracting') return 65
+ if (stage === 'started') return 25
+ return 45
+ }
+
+ const getTaskTotalSteps = (taskType?: string): number => {
+ if (taskType === 'session_commit') return 1
+ if (taskType === 'admin_reindex' || taskType === 'snapshot_restore_reindex') return 2
+ if (taskType === 'connector_import') return 4
+ return 3
}
- const renderStatus = (rawStatus: string | undefined) => {
- const status = normalizeTaskStatus(rawStatus)
- const Icon =
- status === 'completed'
- ? CheckCircle2Icon
- : status === 'failed'
- ? CircleXIcon
- : status === 'running'
- ? LoaderCircleIcon
- : CircleDashedIcon
+ const renderStatus = (task: TaskRecord) => {
+ const taskId = task.task_id
+ // 使用基于 8 并发算力的物理有效状态判定
+ const effStatus = getEffectiveTaskStatus(task, allTasks)
+ const status = normalizeTaskStatus(effStatus)
+ const pct = getTaskProgressPct(task)
+ const isRetrying = retryMutation.isPending && retryMutation.variables?.task_id === taskId
return (
-
- {t(`status.${status}`)}
+
+ {status === 'pending'
+ ? (i18n.language.startsWith('zh') ? '队首等待中' : 'Queued')
+ : t(`status.${status}`)}
+
+ {status === 'running' && (
+
+ {pct}%
+
+ )}
+ {status === 'failed' && (
+
+ )}
)
}
+ const renderQueuePipeline = (task: TaskRecord) => {
+ const status = normalizeTaskStatus(task.status)
+ const type = task.task_type
+
+ interface StepItem {
+ name: string
+ state: 'completed' | 'running' | 'pending' | 'failed'
+ }
+
+ type PipelineGroup =
+ | { type: 'serial'; step: StepItem }
+ | { type: 'parallel'; steps: StepItem[] }
+
+ const groups = getTaskPipelineGroups(task, i18n.language)
+
+ return (
+
+ {groups.map((grp: PipelineGroup, i: number) => {
+ const stepsInGroup = grp.type === 'serial' ? [grp.step] : grp.steps
+
+ return (
+
+ {i > 0 && (
+
+
+
+ )}
+
+ {stepsInGroup.map((st: StepItem, j: number) => {
+ const isDone = st.state === 'completed'
+ const isRun = st.state === 'running'
+ const isFail = st.state === 'failed'
+ const isPend = st.state === 'pending'
+
+ return (
+
+ {isDone && }
+ {isRun && }
+ {isPend && }
+ {isFail && }
+ {st.name}
+
+ )
+ })}
+
+
+ )
+ })}
+
+ )
+ }
+
+ const taskStatsQuery = useQuery({
+ queryFn: async () => {
+ try {
+ const resp = await ovClient.instance.get('/api/v1/tasks/stats')
+ const json = resp.data
+ if (json.result) {
+ return json.result as {
+ total: number
+ completed: number
+ pending: number
+ running: number
+ failed: number
+ }
+ }
+ } catch {
+ // Fallback to local counts if endpoint fails
+ }
+ return null
+ },
+ queryKey: ['taskStats', identityScopeKey],
+ refetchInterval: 5_000,
+ })
+
+ const queueRows = React.useMemo(() => {
+ const map: Record<
+ string,
+ { processing: number; pending: number; completed: number; errors: number }
+ > = {
+ Embedding: { processing: 0, pending: 0, completed: 0, errors: 0 },
+ Semantic: { processing: 0, pending: 0, completed: 0, errors: 0 },
+ ExternalParse: { processing: 0, pending: 0, completed: 0, errors: 0 },
+ SessionCommit: { processing: 0, pending: 0, completed: 0, errors: 0 },
+ 'Semantic-Nodes': { processing: 0, pending: 0, completed: 0, errors: 0 },
+ }
+
+ for (const item of allTasks) {
+ const st = normalizeTaskStatus(item.status)
+ const qStatus = (item.result as any)?.queue_status
+
+ if (item.task_type === 'session_commit') {
+ if (st === 'running') map.SessionCommit.processing++
+ else if (st === 'pending') map.SessionCommit.pending++
+ else if (st === 'completed') map.SessionCommit.completed++
+ else if (st === 'failed') map.SessionCommit.errors++
+ } else if (
+ item.task_type === 'admin_reindex' ||
+ item.task_type === 'snapshot_restore_reindex'
+ ) {
+ if (st === 'pending') map.ExternalParse.pending++
+ else map.ExternalParse.completed++
+
+ if (st === 'running') map.Embedding.processing++
+ else if (st === 'pending') map.Embedding.pending++
+ else if (st === 'completed') map.Embedding.completed++
+ else if (st === 'failed') map.Embedding.errors++
+ } else {
+ // add_resource / add_skill / connector_import 包含:解析 (ExternalParse) -> 语义提炼 (Semantic) + 向量落库 (Embedding)
+ if (st === 'running') {
+ map.ExternalParse.processing++
+ map.Semantic.processing++
+ map.Embedding.processing++
+ } else if (st === 'pending') {
+ map.ExternalParse.pending++
+ map.Semantic.pending++
+ map.Embedding.pending++
+ } else if (st === 'completed') {
+ map.ExternalParse.completed++
+ map.Semantic.completed++
+ map.Embedding.completed++
+ } else if (st === 'failed') {
+ map.ExternalParse.errors++
+ map.Semantic.errors++
+ map.Embedding.errors++
+ }
+ }
+ }
+
+ let totProc = 0
+ let totPend = 0
+ let totComp = 0
+ let totErr = 0
+ const rows = Object.entries(map).map(([name, data]) => {
+ totProc += data.processing
+ totPend += data.pending
+ totComp += data.completed
+ totErr += data.errors
+ return {
+ name,
+ processing: data.processing,
+ pending: data.pending,
+ completed: data.completed,
+ errors: data.errors,
+ total: data.processing + data.pending + data.completed,
+ }
+ })
+
+ rows.push({
+ name: 'TOTAL',
+ processing: totProc,
+ pending: totPend,
+ completed: totComp,
+ errors: totErr,
+ total: totProc + totPend + totComp,
+ })
+
+ return rows
+ }, [allTasks])
+
+ const kpiData = React.useMemo(() => {
+ const total = allTasks.length
+ const completed = allTasks.filter(
+ (item) => normalizeTaskStatus(item.status) === 'completed',
+ ).length
+ const rawRunning = allTasks.filter(
+ (item) => normalizeTaskStatus(item.status) === 'running',
+ ).length
+ const rawPending = allTasks.filter(
+ (item) => normalizeTaskStatus(item.status) === 'pending',
+ ).length
+ const failed = allTasks.filter(
+ (item) => normalizeTaskStatus(item.status) === 'failed',
+ ).length
+
+ // 物理硬件与底层并发槽位限制:Embedding 槽位上限为 8
+ const MAX_CONCURRENT_CAP = 8
+ const running = Math.min(rawRunning, MAX_CONCURRENT_CAP)
+ const pending = rawPending + Math.max(0, rawRunning - MAX_CONCURRENT_CAP)
+
+ const successRate = total > 0 ? (completed / total) * 100 : 100
+
+ const durations = allTasks
+ .map((item) => {
+ const start = Number(item.created_at || 0)
+ const end = Number(item.updated_at || start)
+ return start > 0 && end >= start ? end - start : null
+ })
+ .filter((d): d is number => d !== null && d >= 0)
+
+ const avgDurationSec =
+ durations.length > 0
+ ? durations.reduce((a, b) => a + b, 0) / durations.length
+ : 0
+
+ const ALL_TASK_TYPES = [
+ 'add_resource',
+ 'session_commit',
+ 'admin_reindex',
+ 'snapshot_restore_reindex',
+ 'add_skill',
+ 'connector_import',
+ 'legacy_migration',
+ 'legacy_cleanup',
+ ]
+
+ const typeCounts: Record = {}
+ for (const typeKey of ALL_TASK_TYPES) {
+ typeCounts[typeKey] = 0
+ }
+ for (const item of allTasks) {
+ if (item.task_type) {
+ typeCounts[item.task_type] = (typeCounts[item.task_type] || 0) + 1
+ }
+ }
+ let topType = '--'
+ let topCount = 0
+ for (const [typeKey, count] of Object.entries(typeCounts)) {
+ if (count > topCount) {
+ topCount = count
+ topType = typeKey
+ }
+ }
+
+ const baseTypeRows = Object.entries(typeCounts)
+ .map(([typeKey, count]) => {
+ const matchingTasks = allTasks.filter((taskItem) => taskItem.task_type === typeKey)
+ const processing = matchingTasks.filter(
+ (taskItem) => normalizeTaskStatus(taskItem.status) === 'running',
+ ).length
+ const pending = matchingTasks.filter(
+ (taskItem) => normalizeTaskStatus(taskItem.status) === 'pending',
+ ).length
+ const completed = matchingTasks.filter(
+ (taskItem) => normalizeTaskStatus(taskItem.status) === 'completed',
+ ).length
+ const errors = matchingTasks.filter(
+ (taskItem) => normalizeTaskStatus(taskItem.status) === 'failed',
+ ).length
+ return {
+ name: t(`types.${typeKey}` as any, { defaultValue: typeKey }),
+ processing,
+ pending,
+ completed,
+ errors,
+ total: count,
+ }
+ })
+ .sort((a, b) => b.total - a.total)
+
+ const typeRows = [
+ ...baseTypeRows,
+ {
+ name: 'TOTAL',
+ processing: baseTypeRows.reduce((sum, item) => sum + item.processing, 0),
+ pending: baseTypeRows.reduce((sum, item) => sum + item.pending, 0),
+ completed: baseTypeRows.reduce((sum, item) => sum + item.completed, 0),
+ errors: baseTypeRows.reduce((sum, item) => sum + item.errors, 0),
+ total: baseTypeRows.reduce((sum, item) => sum + item.total, 0),
+ },
+ ]
+
+ return {
+ total,
+ completed,
+ running,
+ pending,
+ failed,
+ successRate,
+ avgDurationSec,
+ topType,
+ topCount,
+ typeRows,
+ }
+ }, [allTasks, t])
+
return (
-
+
@@ -179,24 +688,154 @@ function TasksRoute() {
{t('description')}
-
+
+
+
+ {/* 4 大 Task 核心运行 KPI 观察行 */}
+
+
+
+
+ {i18n.language.startsWith('zh') ? '任务成功率' : 'Success Rate'}
+
+
+
+
+ {kpiData.successRate.toFixed(1)}%
+
+
+
+ {i18n.language.startsWith('zh')
+ ? `共 ${kpiData.total} 条任务 (${kpiData.failed} 异常)`
+ : `Total ${kpiData.total} (${kpiData.failed} Failed)`}
+
+
+
+
+
+
+ {i18n.language.startsWith('zh') ? '平均处理耗时' : 'Avg Duration'}
+
+
+
+
+ {kpiData.avgDurationSec < 1
+ ? `${(kpiData.avgDurationSec * 1000).toFixed(0)}ms`
+ : `${kpiData.avgDurationSec.toFixed(1)}s`}
+
+
+
+ {i18n.language.startsWith('zh')
+ ? '全流程平均处理时长'
+ : 'Avg Processing Time'}
+
+
+
+
+
+
+ {i18n.language.startsWith('zh') ? '任务总数' : 'Total Tasks'}
+
+
+
+
+ {kpiData.total} 条
+
+
+
+ {i18n.language.startsWith('zh')
+ ? `已完成 ${kpiData.completed} 条`
+ : `Completed ${kpiData.completed} Tasks`}
+
+
+
+
+
+
+ {i18n.language.startsWith('zh') ? '并发与排队' : 'Active/Pending'}
+
+
+
+
+ {kpiData.running} / {kpiData.pending}
+
+
+
+ {i18n.language.startsWith('zh')
+ ? '进行中 / 等待中任务'
+ : 'Running / Pending Workloads'}
+
+
+
+
+ {/* 任务队列 (上层) 与 工序队列 (下层) 50/50 并排观测行 */}
+
+ {/* 左侧 (50% 宽度 - 优先看上层任务): 任务队列状态 (Task Queues) */}
+
+
+
+
+ {/* 右侧 (50% 宽度 - 拆分出的下层工序): 工序队列状态 (Process Queues) */}
+
+
+
+
+
{t('filters.label')}
+
- {hasActiveFilters ? (
+ {hasActiveFilters || dataScope !== '24h' ? (
) : null}
+
{tasksQuery.isLoading ? (
@@ -314,7 +970,9 @@ function TasksRoute() {
{t('table.task')}
{t('table.type')}
{t('table.resource')}
+
{i18n.language.startsWith('zh') ? '工序队列流转' : 'Queue Pipeline'}
{t('table.status')}
+
{i18n.language.startsWith('zh') ? '耗时' : 'Duration'}
{t('table.createdAt')}
@@ -357,13 +1015,21 @@ function TasksRoute() {
) : null}
-
- {task.task_type || '-'}
+
+ {task.task_type
+ ? t(`types.${task.task_type}`, {
+ defaultValue: task.task_type,
+ })
+ : '-'}
{task.resource_id || '-'}
- {renderStatus(task.status)}
+ {renderQueuePipeline(task)}
+ {renderStatus(task)}
+
+ {formatTaskDuration(task, i18n.language.startsWith('zh'))}
+
{formatTime(task)}