Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions frontend/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -419,6 +420,7 @@ function runsToStream(
): StreamTask[] {
return runs.map((r) => ({
id: r.id,
href: `/tasks/${r.task_id}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected run in dashboard deep links

When a task has a failed attempt followed by a newer successful retry, this new per-failure link retains only the task ID and discards r.id. The detail page orders runs newest-first and automatically selects runs.data[0], so clicking the failed entry opens the successful attempt and its events instead of the failure being triaged. Include the run ID in the destination and use it as the initial selected run.

Useful? React with 👍 / 👎.

lane: 'collect' as const,
title: `${r.source_name} 采集`,
endpoint: r.source_name,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -582,7 +585,7 @@ export default function DashboardPage() {
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<Link
href="/tasks"
href={hasAttention ? '/tasks?status=failed' : '/tasks'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid filtering run-only failures by current task status

When a failed execution attempt is followed by a successful retry, failures still contains the recent failed TaskRun, so hasAttention is true, but the corresponding CollectionTask is now completed and s.tasks.failed can be zero. In that routine scenario this CTA opens /tasks?status=failed, which excludes the task being highlighted and may show an empty page; route to the recent task context or only apply this filter when the attention state comes from currently failed tasks.

Useful? React with 👍 / 👎.

className={buttonVariants({
variant: hasAttention ? 'destructive' : 'default',
size: 'sm',
Expand Down Expand Up @@ -683,7 +686,7 @@ export default function DashboardPage() {
/>

<section className="grid gap-4" aria-label="运行与异常">
<FailureFeed failures={failures} />
<FailureFeed failures={failures} totalFailed={s.tasks.failed} />
<TaskStream tasks={stream} />
</section>

Expand Down
13 changes: 11 additions & 2 deletions frontend/app/(app)/tasks/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null)
const task = useQuery({ queryKey: ['tasks', id], queryFn: () => getTask(id), refetchInterval: 10_000 })
const source = useQuery({
Expand Down Expand Up @@ -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={
<Link href="/tasks" className={cn(buttonVariants({ variant: 'outline', size: 'sm' }))}>
<Link href={returnTo} className={cn(buttonVariants({ variant: 'outline', size: 'sm' }))}>
<ArrowLeft className="size-4" />
返回工作项
</Link>
Expand Down
146 changes: 110 additions & 36 deletions frontend/app/(app)/tasks/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<Suspense fallback={<TasksPageLoading />}>
<TasksPageContent />
</Suspense>
)
}

function TasksPageLoading() {
return (
<PageContainer
eyebrow="Task history"
title="任务与通知"
description="查看所有采集工作项及其执行状态;需要立即处理的异常会进入待处理视图。"
tabs={<RouteTabs tabs={ACTION_CENTER_TABS} />}
>
<LoadingState />
</PageContainer>
)
}

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 (
<PageContainer
Expand All @@ -48,7 +102,8 @@ export default function TasksPage() {
size="sm"
variant={status === f.key ? 'secondary' : 'ghost'}
className="h-7"
onClick={() => setStatus(f.key)}
aria-pressed={status === f.key}
onClick={() => selectStatus(f.key)}
>
{f.label}
</Button>
Expand All @@ -61,39 +116,58 @@ export default function TasksPage() {
) : isError ? (
<ErrorState message={(error as Error)?.message} hint={BACKEND_HINT} />
) : tasks.length === 0 ? (
<EmptyState title="暂无任务" description="触发采集后,任务会显示在此。" />
<div className="space-y-3">
<EmptyState
title={page > 1 ? '这一页没有任务' : status ? `暂无${activeFilter.label}任务` : '暂无任务'}
description={page > 1 ? '任务数量可能已经变化,请返回第一页继续查看。' : status ? '当前筛选条件下没有任务,可切换状态查看其他工作项。' : '触发采集后,任务会显示在此。'}
/>
{page > 1 ? <Button variant="outline" onClick={() => selectPage(1)}>返回第一页</Button> : null}
</div>
) : (
<Card className="overflow-hidden py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>数据源</TableHead>
<TableHead>触发方式</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead>创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((t) => (
<TableRow key={t.id} className="group">
<TableCell className="font-medium">
<Link href={`/tasks/${t.id}`} className="flex items-center gap-2 hover:underline">
<span>{t.source_name ?? t.source_id}</span>
<ArrowUpRight className="size-3.5 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{t.trigger_type}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{t.priority}</TableCell>
<TableCell>
<StatusBadge status={t.status} />
</TableCell>
<TableCell className="text-muted-foreground">{formatRelative(t.created_at)}</TableCell>
<div className="space-y-3">
<Card className="overflow-hidden py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>数据源</TableHead>
<TableHead>触发方式</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead>创建时间</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</TableHeader>
<TableBody>
{tasks.map((t) => (
<TableRow key={t.id} className="group">
<TableCell className="font-medium">
<Link href={taskDetailPath(t.id, returnTo)} className="flex items-center gap-2 hover:underline">
<span>{t.source_name ?? t.source_id}</span>
<ArrowUpRight className="size-3.5 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{t.trigger_type}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{t.priority}</TableCell>
<TableCell>
<StatusBadge status={t.status} />
</TableCell>
<TableCell className="text-muted-foreground">{formatRelative(t.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<nav className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground" aria-label="任务分页">
<span>共 {totalTasks} 个任务 · 第 {currentPage} / {totalPages} 页</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => selectPage(currentPage - 1)}>
<ChevronLeft className="size-4" />上一页
</Button>
<Button variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => selectPage(currentPage + 1)}>
下一页<ChevronRight className="size-4" />
</Button>
</div>
</nav>
</div>
)}
</PageContainer>
)
Expand Down
72 changes: 26 additions & 46 deletions frontend/components/monitor/task-stream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,42 +32,6 @@ const PHASE_STATUS: Record<StreamTask['phase'], string> = {
failed: 'failed',
}

type GroupedStreamTask = StreamTask & { occurrences: number }
type GroupedFailure = FailureItem & { occurrences: number }

function groupStreamTasks(tasks: StreamTask[]): GroupedStreamTask[] {
const grouped = new Map<string, GroupedStreamTask>()

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<string, GroupedFailure>()

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)

Expand All @@ -93,7 +58,13 @@ export function TaskStream({ tasks }: { tasks: StreamTask[] }) {
<TableRow key={`${t.id}-${t.phase}`}>
<TableCell className="max-w-52">
<span className="flex items-center gap-2">
<span className="block min-w-0 truncate font-medium">{t.title}</span>
{t.href ? (
<Link href={t.href} className="block min-w-0 truncate font-medium hover:underline">
{t.title}
</Link>
) : (
<span className="block min-w-0 truncate font-medium">{t.title}</span>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{t.occurrences > 1 ? (
<Badge variant="secondary" className="shrink-0 font-mono text-[10px]">
×{t.occurrences}
Expand Down Expand Up @@ -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 (
<Card size="sm" aria-label="失败与重试">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-success/10 text-success">
<CheckCircle2 className="size-4" aria-hidden />
<span className={`grid size-9 shrink-0 place-items-center rounded-md ${hasUnlistedFailures ? 'bg-destructive/10 text-destructive' : 'bg-success/10 text-success'}`}>
{hasUnlistedFailures ? <AlertTriangle className="size-4" aria-hidden /> : <CheckCircle2 className="size-4" aria-hidden />}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">当前没有失败任务</p>
<p className="mt-0.5 text-xs text-muted-foreground">最近运行未发现需要重试或人工处理的异常。</p>
<p className="text-sm font-medium">{hasUnlistedFailures ? `${totalFailed} 个失败任务需要处理` : '当前没有失败任务'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{hasUnlistedFailures ? '这些任务不在最近 10 条运行中,请打开失败任务列表继续排查。' : '最近运行未发现需要重试或人工处理的异常。'}
</p>
</div>
</div>
<Link
href="/tasks"
href={hasUnlistedFailures ? '/tasks?status=failed' : '/tasks'}
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
查看运行历史
{hasUnlistedFailures ? '查看失败任务' : '查看运行历史'}
<ArrowRight className="size-3" aria-hidden />
</Link>
</CardContent>
Expand All @@ -174,7 +148,7 @@ export function FailureFeed({ failures }: { failures: FailureItem[] }) {
<CardAction className="flex items-center gap-2">
<Badge variant="destructive">{groupedFailures.length} 类异常</Badge>
<Link
href="/tasks"
href="/tasks?status=failed"
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
查看全部
Expand All @@ -191,7 +165,13 @@ export function FailureFeed({ failures }: { failures: FailureItem[] }) {
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{f.title}</span>
{f.href ? (
<Link href={f.href} className="min-w-0 truncate text-sm font-medium hover:underline">
{f.title}
</Link>
) : (
<span className="truncate text-sm font-medium">{f.title}</span>
)}
<span className="shrink-0 text-xs text-muted-foreground">
{formatRelative(new Date(f.at).toISOString())}
</span>
Expand Down
Loading