fase(19): scheduling module refactor

This commit is contained in:
debian
2026-03-08 05:49:00 -04:00
parent 1cf597fee1
commit 49e76c92b1
39 changed files with 1546 additions and 24 deletions

View File

@@ -22,6 +22,7 @@ import { NotificationsSection } from '@/pages/settings/NotificationsSection'
import { IntegrationsSection } from '@/pages/settings/IntegrationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { SchedulesSection } from '@/pages/settings/SchedulesSection'
import { Reports } from '@/pages/Reports'
function VisualReview() {
@@ -57,6 +58,7 @@ export default function App() {
<Route path="organization" element={<OrganizationSection />} />
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="schedules" element={<SchedulesSection />} />
<Route path="notifications" element={<NotificationsSection />} />
<Route path="integrations" element={<IntegrationsSection />} />
<Route path="appearance" element={<AppearanceSection />} />

View File

@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { Plus, Trash2, Power, PowerOff, Clock } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { apiFetch } from '@/lib/api'
interface Schedule {
id: string
name: string
url: string
cronExpression: string
enabled: boolean
lastRunAt: number | null
nextRunAt: number | null
createdAt: number
}
interface CreateScheduleForm {
name: string
url: string
cronExpression: string
}
function useSchedules() {
return useQuery<Schedule[]>({
queryKey: ['schedules'],
queryFn: () => apiFetch<Schedule[]>('/api/schedules'),
})
}
export function SchedulesSection() {
const qc = useQueryClient()
const { data: schedules, isLoading } = useSchedules()
const [open, setOpen] = useState(false)
const { register, handleSubmit, reset, formState: { errors } } = useForm<CreateScheduleForm>({
defaultValues: { name: '', url: '', cronExpression: '0 * * * *' },
})
const createMutation = useMutation({
mutationFn: (data: CreateScheduleForm) =>
apiFetch('/api/schedules', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schedules'] })
toast.success('Schedule created')
setOpen(false)
reset()
},
onError: (e: Error) => toast.error(e.message),
})
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
apiFetch(`/api/schedules/${id}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['schedules'] }),
onError: (e: Error) => toast.error(e.message),
})
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/schedules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schedules'] })
toast.success('Schedule deleted')
},
onError: (e: Error) => toast.error(e.message),
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Schedules</h2>
<p className="text-sm text-muted-foreground">
Automatically run explorations on a cron schedule.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
New Schedule
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Schedule</DialogTitle>
</DialogHeader>
<form
onSubmit={handleSubmit((data) => createMutation.mutate(data))}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
placeholder="Nightly staging check"
{...register('name', { required: 'Name is required' })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="url">Target URL</Label>
<Input
id="url"
placeholder="https://staging.myapp.com"
{...register('url', { required: 'URL is required' })}
/>
{errors.url && (
<p className="text-xs text-destructive">{errors.url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cronExpression">Cron Expression</Label>
<Input
id="cronExpression"
placeholder="0 2 * * *"
{...register('cronExpression', { required: 'Cron expression is required' })}
/>
{errors.cronExpression && (
<p className="text-xs text-destructive">{errors.cronExpression.message}</p>
)}
<p className="text-xs text-muted-foreground">
Examples: <code>0 2 * * *</code> (daily at 2am),{' '}
<code>0 * * * *</code> (hourly),{' '}
<code>0 9 * * 1</code> (Monday 9am)
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading schedules...</div>
) : !schedules || schedules.length === 0 ? (
<div className="border rounded-lg p-8 text-center text-muted-foreground">
<Clock className="h-8 w-8 mx-auto mb-3 opacity-40" />
<p className="text-sm">No schedules yet.</p>
<p className="text-xs mt-1">Create a schedule to automatically run explorations.</p>
</div>
) : (
<div className="space-y-3">
{schedules.map((schedule) => (
<div
key={schedule.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{schedule.name}</span>
<Badge variant={schedule.enabled ? 'default' : 'secondary'} className="text-xs">
{schedule.enabled ? 'Active' : 'Paused'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">{schedule.url}</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
<code className="bg-muted px-1 rounded">{schedule.cronExpression}</code>
</span>
{schedule.lastRunAt && (
<span>
Last run: {new Date(schedule.lastRunAt).toLocaleString()}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-4">
<Switch
checked={schedule.enabled}
onCheckedChange={(enabled) =>
toggleMutation.mutate({ id: schedule.id, enabled })
}
disabled={toggleMutation.isPending}
/>
{schedule.enabled ? (
<Power className="h-3.5 w-3.5 text-green-500" />
) : (
<PowerOff className="h-3.5 w-3.5 text-muted-foreground" />
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => deleteMutation.mutate(schedule.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug } from 'lucide-react'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
@@ -7,6 +7,7 @@ const navItems = [
{ label: 'Organization', href: '/settings/organization', icon: Building },
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
{ label: 'Schedules', href: '/settings/schedules', icon: Clock },
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },
{ label: 'Integrations', href: '/settings/integrations', icon: Plug },
{ label: 'Appearance', href: '/settings/appearance', icon: Palette },