diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx index f8a71fa6..201f4a0c 100644 --- a/frontend/app/(app)/dashboard/page.tsx +++ b/frontend/app/(app)/dashboard/page.tsx @@ -409,6 +409,7 @@ function OpinionMonitorPanel({ data, isLoading, isError }: { data?: OpinionMonit function runsToStream( runs: Array<{ id: string + task_id: string source_name: string task_trigger_type: string status: string @@ -419,6 +420,7 @@ function runsToStream( ): StreamTask[] { return runs.map((r) => ({ id: r.id, + href: `/tasks/${r.task_id}`, lane: 'collect' as const, title: `${r.source_name} 采集`, endpoint: r.source_name, @@ -521,6 +523,7 @@ export default function DashboardPage() { .filter((task) => task.phase === 'failed') .map((task) => ({ id: `f-${task.id}`, + href: task.href, lane: task.lane, title: task.title, workerName: task.workerName, @@ -582,7 +585,7 @@ export default function DashboardPage() {
- +
diff --git a/frontend/app/(app)/tasks/[id]/page.tsx b/frontend/app/(app)/tasks/[id]/page.tsx index 1980ce77..e4f88ec5 100644 --- a/frontend/app/(app)/tasks/[id]/page.tsx +++ b/frontend/app/(app)/tasks/[id]/page.tsx @@ -13,12 +13,21 @@ import { buttonVariants } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { getSource, getTask, listRunEvents, listTaskRuns } from '@/lib/api/endpoints' import { formatDateTime, formatDuration, formatNumber, formatRelative } from '@/lib/format' +import { normalizeTaskReturnPath } from '@/lib/tasks/query' import { cn } from '@/lib/utils' const TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled']) -export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) { +export default function TaskDetailPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ returnTo?: string | string[] }> +}) { const { id } = use(params) + const query = use(searchParams) + const returnTo = normalizeTaskReturnPath(typeof query.returnTo === 'string' ? query.returnTo : null) const [selectedRunId, setSelectedRunId] = useState(null) const task = useQuery({ queryKey: ['tasks', id], queryFn: () => getTask(id), refetchInterval: 10_000 }) const source = useQuery({ @@ -56,7 +65,7 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin title={source.data?.name ?? item?.source_name ?? `工作项 ${id.slice(0, 8)}`} description={item ? `${item.trigger_type} 触发 · 创建于 ${formatRelative(item.created_at)}` : '查看工作上下文、运行记录、事件与成果。'} actions={ - + 返回工作项 diff --git a/frontend/app/(app)/tasks/page.tsx b/frontend/app/(app)/tasks/page.tsx index 74110d6b..bda8f77d 100644 --- a/frontend/app/(app)/tasks/page.tsx +++ b/frontend/app/(app)/tasks/page.tsx @@ -1,11 +1,20 @@ 'use client' -import { ArrowUpRight } from 'lucide-react' +import { ArrowUpRight, ChevronLeft, ChevronRight } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { Suspense } from 'react' import { useTasks } from '@/lib/api/hooks' import { formatRelative } from '@/lib/format' +import { + normalizeTaskPage, + normalizeTaskStatus, + pathWithQuery, + queryForTaskPage, + queryForTaskStatus, + taskDetailPath, +} from '@/lib/tasks/query' import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states' import { PageContainer } from '@/components/shell/page-container' import { ACTION_CENTER_TABS, RouteTabs } from '@/components/shell/route-tabs' @@ -28,11 +37,56 @@ const STATUS_FILTERS: { key: string; label: string }[] = [ { key: 'failed', label: '失败' }, { key: 'pending', label: '等待中' }, ] +const TASKS_PER_PAGE = 50 export default function TasksPage() { - const [status, setStatus] = useState('') - const { data, isLoading, isError, error } = useTasks(status ? { status } : undefined) + return ( + }> + + + ) +} + +function TasksPageLoading() { + return ( + } + > + + + ) +} + +function TasksPageContent() { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + const status = normalizeTaskStatus(searchParams.get('status')) + const page = normalizeTaskPage(searchParams.get('page')) + const { data, isLoading, isError, error } = useTasks({ + ...(status ? { status } : {}), + page, + limit: TASKS_PER_PAGE, + }) const tasks = data?.data ?? [] + const activeFilter = STATUS_FILTERS.find((filter) => filter.key === status) ?? STATUS_FILTERS[0] + const currentPage = data?.meta?.page ?? page + const totalPages = Math.max(data?.meta?.pages ?? 1, 1) + const totalTasks = data?.meta?.total ?? tasks.length + const returnTo = pathWithQuery(pathname, searchParams.toString()) + + function selectStatus(nextStatus: string) { + const query = queryForTaskStatus(searchParams.toString(), nextStatus) + router.replace(pathWithQuery(pathname, query), { scroll: false }) + } + + function selectPage(nextPage: number) { + const query = queryForTaskPage(searchParams.toString(), nextPage) + router.replace(pathWithQuery(pathname, query), { scroll: false }) + } return ( setStatus(f.key)} + aria-pressed={status === f.key} + onClick={() => selectStatus(f.key)} > {f.label} @@ -61,39 +116,58 @@ export default function TasksPage() { ) : isError ? ( ) : tasks.length === 0 ? ( - +
+ 1 ? '这一页没有任务' : status ? `暂无${activeFilter.label}任务` : '暂无任务'} + description={page > 1 ? '任务数量可能已经变化,请返回第一页继续查看。' : status ? '当前筛选条件下没有任务,可切换状态查看其他工作项。' : '触发采集后,任务会显示在此。'} + /> + {page > 1 ? : null} +
) : ( - - - - - 数据源 - 触发方式 - 优先级 - 状态 - 创建时间 - - - - {tasks.map((t) => ( - - - - {t.source_name ?? t.source_id} - - - - {t.trigger_type} - {t.priority} - - - - {formatRelative(t.created_at)} +
+ +
+ + + 数据源 + 触发方式 + 优先级 + 状态 + 创建时间 - ))} - -
-
+ + + {tasks.map((t) => ( + + + + {t.source_name ?? t.source_id} + + + + {t.trigger_type} + {t.priority} + + + + {formatRelative(t.created_at)} + + ))} + + + + +
)} ) diff --git a/frontend/components/monitor/task-stream.tsx b/frontend/components/monitor/task-stream.tsx index 49f1c5c2..8dc212ea 100644 --- a/frontend/components/monitor/task-stream.tsx +++ b/frontend/components/monitor/task-stream.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, ArrowRight, CheckCircle2 } from 'lucide-react' import type { FailureItem, StreamTask } from '@/lib/demo/monitor' import { formatDuration, formatRelative } from '@/lib/format' +import { groupFailures, groupStreamTasks } from '@/lib/monitor/task-grouping' import { StatusBadge } from '@/components/shell/status-badge' import { Badge } from '@/components/ui/badge' import { @@ -31,42 +32,6 @@ const PHASE_STATUS: Record = { failed: 'failed', } -type GroupedStreamTask = StreamTask & { occurrences: number } -type GroupedFailure = FailureItem & { occurrences: number } - -function groupStreamTasks(tasks: StreamTask[]): GroupedStreamTask[] { - const grouped = new Map() - - for (const task of tasks) { - const key = [task.title, task.lane, task.workerName, task.phase].join('\u0000') - const existing = grouped.get(key) - if (existing) { - existing.occurrences += 1 - existing.records += task.records - continue - } - grouped.set(key, { ...task, occurrences: 1 }) - } - - return Array.from(grouped.values()).slice(0, 6) -} - -function groupFailures(failures: FailureItem[]): GroupedFailure[] { - const grouped = new Map() - - for (const failure of failures) { - const key = [failure.title, failure.workerName, failure.error].join('\u0000') - const existing = grouped.get(key) - if (existing) { - existing.occurrences += 1 - continue - } - grouped.set(key, { ...failure, occurrences: 1 }) - } - - return Array.from(grouped.values()).slice(0, 5) -} - export function TaskStream({ tasks }: { tasks: StreamTask[] }) { const groupedTasks = groupStreamTasks(tasks) @@ -93,7 +58,13 @@ export function TaskStream({ tasks }: { tasks: StreamTask[] }) { - {t.title} + {t.href ? ( + + {t.title} + + ) : ( + {t.title} + )} {t.occurrences > 1 ? ( ×{t.occurrences} @@ -135,27 +106,30 @@ export function TaskStream({ tasks }: { tasks: StreamTask[] }) { ) } -export function FailureFeed({ failures }: { failures: FailureItem[] }) { +export function FailureFeed({ failures, totalFailed = failures.length }: { failures: FailureItem[]; totalFailed?: number }) { const groupedFailures = groupFailures(failures) if (groupedFailures.length === 0) { + const hasUnlistedFailures = totalFailed > 0 return (
- - + + {hasUnlistedFailures ? : }
-

当前没有失败任务

-

最近运行未发现需要重试或人工处理的异常。

+

{hasUnlistedFailures ? `${totalFailed} 个失败任务需要处理` : '当前没有失败任务'}

+

+ {hasUnlistedFailures ? '这些任务不在最近 10 条运行中,请打开失败任务列表继续排查。' : '最近运行未发现需要重试或人工处理的异常。'} +

- 查看运行历史 + {hasUnlistedFailures ? '查看失败任务' : '查看运行历史'}
@@ -174,7 +148,7 @@ export function FailureFeed({ failures }: { failures: FailureItem[] }) { {groupedFailures.length} 类异常 查看全部 @@ -191,7 +165,13 @@ export function FailureFeed({ failures }: { failures: FailureItem[] }) { >
- {f.title} + {f.href ? ( + + {f.title} + + ) : ( + {f.title} + )} {formatRelative(new Date(f.at).toISOString())} diff --git a/frontend/lib/demo/monitor.ts b/frontend/lib/demo/monitor.ts index 9e50ffca..5e2e8dd8 100644 --- a/frontend/lib/demo/monitor.ts +++ b/frontend/lib/demo/monitor.ts @@ -30,6 +30,8 @@ export interface WorkerView { export interface StreamTask { id: string + /** User-facing destination for the authoritative task context, when available. */ + href?: string lane: LaneKind title: string /** Collect: source name. Dispatch: destination channel. */ @@ -45,6 +47,8 @@ export interface StreamTask { export interface FailureItem { id: string + /** User-facing destination for the authoritative task context, when available. */ + href?: string lane: LaneKind title: string workerName: string diff --git a/frontend/lib/monitor/task-grouping.ts b/frontend/lib/monitor/task-grouping.ts new file mode 100644 index 00000000..e6758edf --- /dev/null +++ b/frontend/lib/monitor/task-grouping.ts @@ -0,0 +1,37 @@ +import type { FailureItem, StreamTask } from '@/lib/demo/monitor' + +export type GroupedStreamTask = StreamTask & { occurrences: number } +export type GroupedFailure = FailureItem & { occurrences: number } + +export function groupStreamTasks(tasks: StreamTask[]): GroupedStreamTask[] { + const grouped = new Map() + + for (const task of tasks) { + const key = [task.href ?? '', task.title, task.lane, task.workerName, task.phase].join('\u0000') + const existing = grouped.get(key) + if (existing) { + existing.occurrences += 1 + existing.records += task.records + continue + } + grouped.set(key, { ...task, occurrences: 1 }) + } + + return Array.from(grouped.values()).slice(0, 6) +} + +export function groupFailures(failures: FailureItem[]): GroupedFailure[] { + const grouped = new Map() + + for (const failure of failures) { + const key = [failure.href ?? '', failure.title, failure.workerName, failure.error].join('\u0000') + const existing = grouped.get(key) + if (existing) { + existing.occurrences += 1 + continue + } + grouped.set(key, { ...failure, occurrences: 1 }) + } + + return Array.from(grouped.values()).slice(0, 5) +} diff --git a/frontend/lib/tasks/query.ts b/frontend/lib/tasks/query.ts new file mode 100644 index 00000000..c847ab63 --- /dev/null +++ b/frontend/lib/tasks/query.ts @@ -0,0 +1,44 @@ +const TASK_STATUSES = new Set(['running', 'completed', 'failed', 'pending']) + +export function normalizeTaskStatus(value: string | null): string { + return value && TASK_STATUSES.has(value) ? value : '' +} + +export function normalizeTaskPage(value: string | null): number { + const page = Number(value) + return Number.isInteger(page) && page > 0 ? page : 1 +} + +export function queryForTaskStatus(currentQuery: string, nextStatus: string): string { + const params = new URLSearchParams(currentQuery) + const status = normalizeTaskStatus(nextStatus) + if (status) params.set('status', status) + else params.delete('status') + params.delete('page') + return params.toString() +} + +export function queryForTaskPage(currentQuery: string, nextPage: number): string { + const params = new URLSearchParams(currentQuery) + const page = Number.isInteger(nextPage) && nextPage > 1 ? nextPage : 1 + if (page > 1) params.set('page', String(page)) + else params.delete('page') + return params.toString() +} + +export function pathWithQuery(pathname: string, query: string): string { + return query ? `${pathname}?${query}` : pathname +} + +export function taskDetailPath(taskId: string, returnTo: string): string { + const params = new URLSearchParams({ returnTo: normalizeTaskReturnPath(returnTo) }) + return `/tasks/${encodeURIComponent(taskId)}?${params.toString()}` +} + +export function normalizeTaskReturnPath(value: string | null): string { + if (!value?.startsWith('/')) return '/tasks' + const base = new URL('https://opencli.local/tasks') + const target = new URL(value, base) + if (target.origin !== base.origin || target.pathname !== '/tasks') return '/tasks' + return `${target.pathname}${target.search}` +} diff --git a/frontend/package.json b/frontend/package.json index 477be7e3..f50ba369 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "check:image-studio": "node --test scripts/check-image-studio-contract.mjs", "check:navigation-transitions": "node --test scripts/check-navigation-transition-regressions.mjs", "check:control-plane": "node --test scripts/check-control-plane-regressions.mjs scripts/check-dashboard-regressions.mjs scripts/check-inbox-regressions.mjs scripts/check-visualization-regressions.mjs", + "check:task-triage": "node --experimental-strip-types --test scripts/task-list-query.test.mjs scripts/task-grouping.test.mjs scripts/check-dashboard-regressions.mjs", "check:dify-p0": "node --test scripts/check-dify-p0-regressions.mjs", "check:node-capabilities": "node --test scripts/check-node-capability-catalog-regressions.mjs scripts/check-tool-capability-catalog-regressions.mjs", "check:record-hygiene": "node --test scripts/check-record-hygiene-regressions.mjs", diff --git a/frontend/scripts/check-dashboard-regressions.mjs b/frontend/scripts/check-dashboard-regressions.mjs index 64e1bca3..ac5b4def 100644 --- a/frontend/scripts/check-dashboard-regressions.mjs +++ b/frontend/scripts/check-dashboard-regressions.mjs @@ -56,6 +56,7 @@ test('dashboard keeps existing real operational views after the action layer', a assert.match(dashboard, //) assert.match(dashboard, / { + const [dashboard, taskStream, taskGrouping, tasksPage, taskDetailPage] = await Promise.all([ + read('app/(app)/dashboard/page.tsx'), + read('components/monitor/task-stream.tsx'), + read('lib/monitor/task-grouping.ts'), + read('app/(app)/tasks/page.tsx'), + read('app/(app)/tasks/[id]/page.tsx'), + ]) + + assert.match(dashboard, /href: `\/tasks\/\$\{r\.task_id\}`/) + assert.match(dashboard, /href: task\.href/) + assert.match(dashboard, /hasAttention \? '\/tasks\?status=failed' : '\/tasks'/) + assert.match(taskStream, /href="\/tasks\?status=failed"/) + assert.match(taskStream, /hasUnlistedFailures = totalFailed > 0/) + assert.match(taskStream, /这些任务不在最近 10 条运行中/) + assert.match(taskStream, /hasUnlistedFailures \? '\/tasks\?status=failed' : '\/tasks'/) + assert.match(taskStream, /= totalPages\}/) + assert.match(tasksPage, /taskDetailPath\(t\.id, returnTo\)/) + assert.match(taskDetailPage, /normalizeTaskReturnPath\(typeof query\.returnTo === 'string' \? query\.returnTo : null\)/) + assert.match(taskDetailPage, / { + const grouped = groupStreamTasks([ + streamTask, + { ...streamTask, id: 'run-2', href: '/tasks/task-2', records: 2 }, + ]) + + assert.equal(grouped.length, 2) + assert.deepEqual(grouped.map((task) => task.href), ['/tasks/task-1', '/tasks/task-2']) +}) + +test('stream grouping still combines repeated runs of the same task', () => { + const grouped = groupStreamTasks([ + streamTask, + { ...streamTask, id: 'run-2', records: 2 }, + ]) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].occurrences, 2) + assert.equal(grouped[0].records, 3) + assert.equal(grouped[0].href, '/tasks/task-1') +}) + +test('failure grouping preserves distinct task destinations', () => { + const failure = { + id: 'failure-1', + href: '/tasks/task-1', + lane: 'collect', + title: '同名采集', + workerName: 'worker', + error: 'timeout', + retries: 0, + at: 1, + } + const grouped = groupFailures([ + failure, + { ...failure, id: 'failure-2', href: '/tasks/task-2' }, + ]) + + assert.equal(grouped.length, 2) + assert.deepEqual(grouped.map((item) => item.href), ['/tasks/task-1', '/tasks/task-2']) +}) diff --git a/frontend/scripts/task-list-query.test.mjs b/frontend/scripts/task-list-query.test.mjs new file mode 100644 index 00000000..ef7d5567 --- /dev/null +++ b/frontend/scripts/task-list-query.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + normalizeTaskPage, + normalizeTaskReturnPath, + normalizeTaskStatus, + pathWithQuery, + queryForTaskPage, + queryForTaskStatus, + taskDetailPath, +} from '../lib/tasks/query.ts' + +test('task filters accept only supported status and positive integer pages', () => { + assert.equal(normalizeTaskStatus('failed'), 'failed') + assert.equal(normalizeTaskStatus('unknown'), '') + assert.equal(normalizeTaskStatus(null), '') + assert.equal(normalizeTaskPage('3'), 3) + assert.equal(normalizeTaskPage('0'), 1) + assert.equal(normalizeTaskPage('2.5'), 1) + assert.equal(normalizeTaskPage('not-a-number'), 1) +}) + +test('changing status resets pagination while preserving unrelated context', () => { + assert.equal(queryForTaskStatus('page=4&source=alpha', 'failed'), 'source=alpha&status=failed') + assert.equal(queryForTaskStatus('status=failed&page=2&source=alpha', ''), 'source=alpha') + assert.equal(queryForTaskStatus('page=2', 'unknown'), '') +}) + +test('changing pages keeps filters and canonicalizes the first page', () => { + assert.equal(queryForTaskPage('status=failed&source=alpha', 3), 'status=failed&source=alpha&page=3') + assert.equal(queryForTaskPage('status=failed&page=3', 1), 'status=failed') + assert.equal(queryForTaskPage('status=failed&page=3', -1), 'status=failed') + assert.equal(pathWithQuery('/tasks', 'status=failed&page=3'), '/tasks?status=failed&page=3') + assert.equal(pathWithQuery('/tasks', ''), '/tasks') +}) + +test('task detail links preserve only a safe task-list return context', () => { + const returnTo = '/tasks?status=failed&page=3' + const detail = taskDetailPath('task / 1', returnTo) + const parsed = new URL(detail, 'https://opencli.local') + + assert.equal(parsed.pathname, '/tasks/task%20%2F%201') + assert.equal(parsed.searchParams.get('returnTo'), returnTo) + assert.equal(normalizeTaskReturnPath(returnTo), returnTo) + assert.equal(normalizeTaskReturnPath('https://evil.example/tasks'), '/tasks') + assert.equal(normalizeTaskReturnPath('//evil.example/tasks'), '/tasks') + assert.equal(normalizeTaskReturnPath('/settings'), '/tasks') + assert.equal(normalizeTaskReturnPath('/tasks/task-1'), '/tasks') +})