mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-17 02:25:57 +00:00
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:
89
apps/agent/components/ui/tabs.tsx
Normal file
89
apps/agent/components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -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=="],
|
||||
|
||||
Reference in New Issue
Block a user