mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
feat: group scheduled task results (bosmain-obt)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type {
|
||||
ScheduledJob,
|
||||
ScheduledJobRun,
|
||||
} from '@/lib/schedules/scheduleTypes'
|
||||
import { groupScheduledTaskRuns } from './scheduledTaskResultsUtils'
|
||||
|
||||
function makeJob(input: Pick<ScheduledJob, 'id' | 'name'>): ScheduledJob {
|
||||
return {
|
||||
...input,
|
||||
query: `Query for ${input.name}`,
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function makeRun(input: {
|
||||
id: string
|
||||
jobId: string
|
||||
startedAt: string
|
||||
status?: ScheduledJobRun['status']
|
||||
}): ScheduledJobRun {
|
||||
return {
|
||||
id: input.id,
|
||||
jobId: input.jobId,
|
||||
startedAt: input.startedAt,
|
||||
status: input.status ?? 'completed',
|
||||
result: `Result for ${input.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupScheduledTaskRuns', () => {
|
||||
it('groups runs by scheduled task and sorts groups by latest run', () => {
|
||||
const groups = groupScheduledTaskRuns({
|
||||
jobs: [makeJob({ id: 'news', name: 'Morning News' })],
|
||||
runs: [
|
||||
makeRun({
|
||||
id: 'news-old',
|
||||
jobId: 'news',
|
||||
startedAt: '2026-01-02T09:00:00.000Z',
|
||||
}),
|
||||
makeRun({
|
||||
id: 'prices-new',
|
||||
jobId: 'prices',
|
||||
startedAt: '2026-01-03T14:00:00.000Z',
|
||||
}),
|
||||
makeRun({
|
||||
id: 'news-new',
|
||||
jobId: 'news',
|
||||
startedAt: '2026-01-04T09:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(groups.map((group) => group.id)).toEqual(['news', 'prices'])
|
||||
expect(groups[0]).toMatchObject({
|
||||
id: 'news',
|
||||
name: 'Morning News',
|
||||
resultCount: 2,
|
||||
latestRun: { id: 'news-new' },
|
||||
})
|
||||
expect(groups[0]?.runs.map((run) => run.id)).toEqual([
|
||||
'news-new',
|
||||
'news-old',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps missing jobs visible under an unknown task label', () => {
|
||||
const groups = groupScheduledTaskRuns({
|
||||
jobs: [],
|
||||
runs: [
|
||||
makeRun({
|
||||
id: 'orphan-run',
|
||||
jobId: 'deleted-job',
|
||||
startedAt: '2026-01-02T09:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).toMatchObject({
|
||||
id: 'deleted-job',
|
||||
name: 'Unknown scheduled task',
|
||||
resultCount: 1,
|
||||
latestRun: { id: 'orphan-run' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps running runs first without changing the latest-run header data', () => {
|
||||
const groups = groupScheduledTaskRuns({
|
||||
jobs: [makeJob({ id: 'news', name: 'Morning News' })],
|
||||
runs: [
|
||||
makeRun({
|
||||
id: 'completed-new',
|
||||
jobId: 'news',
|
||||
startedAt: '2026-01-04T09:00:00.000Z',
|
||||
status: 'completed',
|
||||
}),
|
||||
makeRun({
|
||||
id: 'running-old',
|
||||
jobId: 'news',
|
||||
startedAt: '2026-01-03T09:00:00.000Z',
|
||||
status: 'running',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(groups[0]?.latestRun.id).toBe('completed-new')
|
||||
expect(groups[0]?.runs.map((run) => run.id)).toEqual([
|
||||
'running-old',
|
||||
'completed-new',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
@@ -10,23 +11,25 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
useScheduledJobRuns,
|
||||
useScheduledJobs,
|
||||
} from '@/lib/schedules/scheduleStorage'
|
||||
import type {
|
||||
ScheduledJob,
|
||||
ScheduledJobRun,
|
||||
} from '@/lib/schedules/scheduleTypes'
|
||||
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
|
||||
import {
|
||||
groupScheduledTaskRuns,
|
||||
type JobRunWithDetails,
|
||||
} from './scheduledTaskResultsUtils'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
interface JobRunWithDetails extends ScheduledJobRun {
|
||||
job: ScheduledJob | undefined
|
||||
}
|
||||
|
||||
interface ScheduledTaskResultsProps {
|
||||
onViewRun: (run: ScheduledJobRun) => void
|
||||
onCancelRun: (runId: string) => void
|
||||
@@ -46,6 +49,22 @@ const getStatusIcon = (status: JobRunWithDetails['status']) => {
|
||||
|
||||
const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()
|
||||
|
||||
const formatRunTimestamp = (dateString: string) => {
|
||||
const date = dayjs(dateString)
|
||||
|
||||
if (date.isSame(dayjs(), 'day')) {
|
||||
return `Today, ${date.format('h:mm A')}`
|
||||
}
|
||||
if (date.isSame(dayjs().subtract(1, 'day'), 'day')) {
|
||||
return `Yesterday, ${date.format('h:mm A')}`
|
||||
}
|
||||
|
||||
return date.format('MMM D, h:mm A')
|
||||
}
|
||||
|
||||
const getRunPreview = (run: JobRunWithDetails) =>
|
||||
run.finalResult ?? run.result ?? run.error
|
||||
|
||||
export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
|
||||
onViewRun,
|
||||
onCancelRun,
|
||||
@@ -54,28 +73,23 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
|
||||
const { jobRuns } = useScheduledJobRuns()
|
||||
const { jobs } = useScheduledJobs()
|
||||
|
||||
const sortedRuns: JobRunWithDetails[] = useMemo(() => {
|
||||
const enrichWithJob = (run: ScheduledJobRun): JobRunWithDetails => ({
|
||||
...run,
|
||||
job: jobs.find((j) => j.id === run.jobId),
|
||||
})
|
||||
const taskGroups = useMemo(
|
||||
() => groupScheduledTaskRuns({ runs: jobRuns, jobs }),
|
||||
[jobRuns, jobs],
|
||||
)
|
||||
const [expandedGroupId, setExpandedGroupId] = useState<
|
||||
string | null | undefined
|
||||
>(undefined)
|
||||
|
||||
const running = jobRuns
|
||||
.filter((r) => r.status === 'running')
|
||||
.map(enrichWithJob)
|
||||
const visibleExpandedGroupId =
|
||||
expandedGroupId === undefined
|
||||
? (taskGroups[0]?.id ?? null)
|
||||
: expandedGroupId !== null &&
|
||||
!taskGroups.some((group) => group.id === expandedGroupId)
|
||||
? (taskGroups[0]?.id ?? null)
|
||||
: expandedGroupId
|
||||
|
||||
const completedOrFailed = jobRuns
|
||||
.filter((r) => r.status === 'completed' || r.status === 'failed')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
)
|
||||
.map(enrichWithJob)
|
||||
|
||||
return [...running, ...completedOrFailed]
|
||||
}, [jobRuns, jobs])
|
||||
|
||||
if (!sortedRuns.length) {
|
||||
if (!taskGroups.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
|
||||
<Calendar className="h-10 w-10 opacity-50" />
|
||||
@@ -85,62 +99,107 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sortedRuns.map((run) => (
|
||||
<Button
|
||||
key={run.id}
|
||||
variant="ghost"
|
||||
onClick={() => onViewRun(run)}
|
||||
className="h-auto w-full justify-start rounded-xl border border-border/50 bg-card p-4 text-left transition-all hover:border-border"
|
||||
<div className="space-y-3">
|
||||
{taskGroups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.id}
|
||||
open={visibleExpandedGroupId === group.id}
|
||||
onOpenChange={(open) => setExpandedGroupId(open ? group.id : null)}
|
||||
className="rounded-xl border border-border bg-card shadow-sm transition-all hover:border-border"
|
||||
>
|
||||
<div className="flex w-full items-start gap-3">
|
||||
{getStatusIcon(run.status)}
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/40">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
|
||||
visibleExpandedGroupId === group.id ? '' : '-rotate-90'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{getStatusIcon(group.latestRun.status)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{run.job?.name}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTimestamp(run.startedAt)}
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||
{group.resultCount}{' '}
|
||||
{group.resultCount === 1 ? 'result' : 'results'}
|
||||
</span>
|
||||
</div>
|
||||
{run.result && (
|
||||
<p className="line-clamp-2 text-ellipsis text-muted-foreground text-xs">
|
||||
{run.result}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Latest {formatTimestamp(group.latestRun.startedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{run.status === 'running' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCancelRun(run.id)
|
||||
}}
|
||||
className="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Cancel run"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{run.status === 'failed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetryRun(run.jobId)
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Retry run"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border border-t px-4 pt-3 pb-4">
|
||||
<div className="space-y-2">
|
||||
{group.runs.map((run) => {
|
||||
const preview = getRunPreview(run)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-start gap-3 rounded-lg border border-border bg-background p-3"
|
||||
>
|
||||
<div className="pt-0.5">{getStatusIcon(run.status)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewRun(run)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground text-sm">
|
||||
{formatRunTimestamp(run.startedAt)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
{preview && (
|
||||
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{run.status === 'running' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onCancelRun(run.id)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Cancel run"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{run.status === 'failed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onRetryRun(run.jobId)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Retry run"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewRun(run)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
ScheduledJob,
|
||||
ScheduledJobRun,
|
||||
} from '@/lib/schedules/scheduleTypes'
|
||||
|
||||
export interface JobRunWithDetails extends ScheduledJobRun {
|
||||
job: ScheduledJob | undefined
|
||||
}
|
||||
|
||||
export interface ScheduledTaskRunGroup {
|
||||
id: string
|
||||
name: string
|
||||
job: ScheduledJob | undefined
|
||||
runs: JobRunWithDetails[]
|
||||
latestRun: JobRunWithDetails
|
||||
resultCount: number
|
||||
}
|
||||
|
||||
interface GroupScheduledTaskRunsInput {
|
||||
jobs: ScheduledJob[]
|
||||
runs: ScheduledJobRun[]
|
||||
}
|
||||
|
||||
const getStartedAtMs = (run: ScheduledJobRun) => {
|
||||
const time = new Date(run.startedAt).getTime()
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
|
||||
const compareRunsByNewest = (a: ScheduledJobRun, b: ScheduledJobRun) =>
|
||||
getStartedAtMs(b) - getStartedAtMs(a)
|
||||
|
||||
const compareRunsForDisplay = (a: ScheduledJobRun, b: ScheduledJobRun) => {
|
||||
if (a.status === 'running' && b.status !== 'running') return -1
|
||||
if (a.status !== 'running' && b.status === 'running') return 1
|
||||
return compareRunsByNewest(a, b)
|
||||
}
|
||||
|
||||
export function groupScheduledTaskRuns({
|
||||
jobs,
|
||||
runs,
|
||||
}: GroupScheduledTaskRunsInput): ScheduledTaskRunGroup[] {
|
||||
const jobsById = new Map(jobs.map((job) => [job.id, job]))
|
||||
const groupsByJobId = new Map<string, JobRunWithDetails[]>()
|
||||
|
||||
for (const run of runs) {
|
||||
const job = jobsById.get(run.jobId)
|
||||
const enrichedRun: JobRunWithDetails = { ...run, job }
|
||||
const existing = groupsByJobId.get(run.jobId)
|
||||
|
||||
if (existing) {
|
||||
existing.push(enrichedRun)
|
||||
} else {
|
||||
groupsByJobId.set(run.jobId, [enrichedRun])
|
||||
}
|
||||
}
|
||||
|
||||
return [...groupsByJobId.entries()]
|
||||
.map(([jobId, groupRuns]) => {
|
||||
const sortedRuns = [...groupRuns].sort(compareRunsForDisplay)
|
||||
const latestRun = [...groupRuns].sort(compareRunsByNewest)[0]
|
||||
const job = jobsById.get(jobId)
|
||||
|
||||
return {
|
||||
id: jobId,
|
||||
name: job?.name ?? 'Unknown scheduled task',
|
||||
job,
|
||||
runs: sortedRuns,
|
||||
latestRun,
|
||||
resultCount: sortedRuns.length,
|
||||
}
|
||||
})
|
||||
.filter((group): group is ScheduledTaskRunGroup => Boolean(group.latestRun))
|
||||
.sort((a, b) => compareRunsByNewest(a.latestRun, b.latestRun))
|
||||
}
|
||||
Reference in New Issue
Block a user