fase(19): scheduling module refactor
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
228
frontend/src/pages/settings/SchedulesSection.tsx
Normal file
228
frontend/src/pages/settings/SchedulesSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user