feat: improved scheduled task results (#290)

* feat: new scheduled task results

* feat: run missed tasks in scheduled tasks

* fix: added a missed job guard to prevent duplicate runs
This commit is contained in:
Dani Akash
2026-02-02 15:58:21 +05:30
committed by GitHub
parent a5ac1ed054
commit 24971a144a
6 changed files with 278 additions and 9 deletions

View File

@@ -0,0 +1,89 @@
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Tabs({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col',
className,
)}
{...props}
/>
)
}
const tabsListVariants = cva(
'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
className,
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,107 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { Calendar, CheckCircle2, Clock, Loader2, XCircle } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import {
useScheduledJobRuns,
useScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
dayjs.extend(relativeTime)
interface JobRunWithDetails extends ScheduledJobRun {
job: ScheduledJob | undefined
}
interface ScheduledTaskResultsProps {
onViewRun: (run: ScheduledJobRun) => void
}
const getStatusIcon = (status: JobRunWithDetails['status']) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-accent-orange" />
case 'failed':
return <XCircle className="h-4 w-4 text-destructive" />
}
}
const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()
export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
onViewRun,
}) => {
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 running = jobRuns
.filter((r) => r.status === 'running')
.map(enrichWithJob)
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) {
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" />
<p className="text-sm">No task runs yet</p>
</div>
)
}
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="flex w-full items-start gap-3">
{getStatusIcon(run.status)}
<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}
</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3 w-3" />
{formatTimestamp(run.startedAt)}
</span>
</div>
{run.result && (
<p className="line-clamp-2 text-ellipsis text-muted-foreground text-xs">
{run.result}
</p>
)}
</div>
</div>
</Button>
))}
</div>
)
}

View File

@@ -10,6 +10,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
NEW_SCHEDULED_TASK_CREATED_EVENT,
SCHEDULED_TASK_DELETED_EVENT,
@@ -24,6 +25,7 @@ import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedule
import { useScheduledJobs } from '@/lib/schedules/scheduleStorage'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import { NewScheduledTaskDialog } from './NewScheduledTaskDialog'
import { ScheduledTaskResults } from './ScheduledTaskResults'
import { ScheduledTasksHeader } from './ScheduledTasksHeader'
import { ScheduledTasksList } from './ScheduledTasksList'
import type { ScheduledJob } from './types'
@@ -109,14 +111,27 @@ export const ScheduledTasksPage: FC = () => {
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<ScheduledTasksHeader onAddClick={handleAdd} />
<ScheduledTasksList
jobs={jobs}
onEdit={handleEdit}
onDelete={handleDelete}
onToggle={handleToggle}
onRun={handleRun}
onViewRun={handleViewRun}
/>
<Tabs defaultValue="results">
<TabsList>
<TabsTrigger value="results">Results</TabsTrigger>
<TabsTrigger value="tasks">Scheduled Tasks</TabsTrigger>
</TabsList>
<TabsContent value="results">
<ScheduledTaskResults onViewRun={handleViewRun} />
</TabsContent>
<TabsContent value="tasks">
<ScheduledTasksList
jobs={jobs}
onEdit={handleEdit}
onDelete={handleDelete}
onToggle={handleToggle}
onRun={handleRun}
onViewRun={handleViewRun}
/>
</TabsContent>
</Tabs>
<NewScheduledTaskDialog
open={isDialogOpen}

View File

@@ -9,6 +9,7 @@ import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
const MAX_RUNS_PER_JOB = 15
const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000
export const scheduledJobRuns = async () => {
const cleanupStaleJobRuns = async () => {
@@ -158,6 +159,57 @@ export const scheduledJobRuns = async () => {
}
}
let runningMissedJobs = false
const runMissedJobs = async () => {
if (runningMissedJobs) return
runningMissedJobs = true
try {
const jobs = (await scheduledJobStorage.getValue()).filter(
(j) => j.enabled,
)
const runs = (await scheduledJobRunStorage.getValue()) ?? []
const now = Date.now()
const cutoff = now - TWENTY_FOUR_HOURS_MS
for (const job of jobs) {
const hasRecentRun = runs.some(
(r) => r.jobId === job.id && new Date(r.startedAt).getTime() > cutoff,
)
if (hasRecentRun) continue
const hasRunningRun = runs.some(
(r) => r.jobId === job.id && r.status === 'running',
)
if (hasRunningRun) continue
if (job.scheduleType === 'daily' && job.scheduleTime) {
const [hours, minutes] = job.scheduleTime.split(':').map(Number)
const scheduledToday = new Date()
scheduledToday.setHours(hours, minutes, 0, 0)
if (now < scheduledToday.getTime()) continue
}
if (
(job.scheduleType === 'hourly' || job.scheduleType === 'minutes') &&
job.scheduleInterval
) {
const intervalMs =
job.scheduleType === 'hourly'
? job.scheduleInterval * 60 * 60 * 1000
: job.scheduleInterval * 60 * 1000
const createdAt = new Date(job.createdAt).getTime()
if (now - createdAt < intervalMs) continue
}
await executeScheduledJob(job.id)
}
} finally {
runningMissedJobs = false
}
}
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (!alarm.name.startsWith('scheduled-job-')) return
const jobId = alarm.name.replace('scheduled-job-', '')
@@ -179,10 +231,12 @@ export const scheduledJobRuns = async () => {
chrome.runtime.onStartup.addListener(async () => {
await cleanupStaleJobRuns()
await syncAlarmState()
await runMissedJobs()
})
chrome.runtime.onInstalled.addListener(async () => {
await cleanupStaleJobRuns()
await syncAlarmState()
await runMissedJobs()
})
}

View File

@@ -38,6 +38,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@sentry/react": "^10.31.0",

View File

@@ -41,6 +41,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@sentry/react": "^10.31.0",
@@ -137,7 +138,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.50",
"version": "0.0.51",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -1063,6 +1064,8 @@
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],