wulf-pulse/components/admin/SyncScheduler.tsx
root f117210c9d feat: add scheduled sync system with admin UI
Implements comprehensive scheduled sync system using node-cron with
full admin interface for configuration and monitoring.

Features:
- Configurable sync schedules with cron expressions
- Enable/disable schedules without deletion
- Manual trigger for testing
- Status monitoring (last run, next run, success/failure)
- Error tracking and display
- Incremental and full sync support
- Multiple concurrent schedules
- Admin UI with schedule management

Components:

1. Sync Scheduler Service (lib/services/sync-scheduler.ts)
   - node-cron integration for scheduling
   - Database-backed schedule configuration
   - Automatic initialization on startup
   - Prevents concurrent runs of same schedule
   - Calculates next run times
   - Tracks execution status and errors

2. Database Schema (sync_schedules table)
   - Schedule configuration storage
   - Execution history tracking
   - Last run status and errors
   - Next run calculation

3. API Endpoints
   - GET /api/sync/schedules - List all schedules
   - POST /api/sync/schedules - Create schedule
   - GET /api/sync/schedules/[id] - Get schedule
   - PATCH /api/sync/schedules/[id] - Update schedule
   - DELETE /api/sync/schedules/[id] - Delete schedule
   - POST /api/sync/schedules/[id]/trigger - Manual trigger

4. Admin UI (components/admin/SyncScheduler.tsx)
   - View all schedules with status
   - Create/edit/delete schedules
   - Enable/disable toggle
   - Manual trigger button
   - Cron expression presets
   - Real-time status updates
   - Error message display
   - Next run countdown

5. Default Schedules (created on first startup, disabled)
   - Daily Incremental: 2 AM daily (0 2 * * *)
   - Weekly Full: 3 AM Sunday (0 3 * * 0)

Admin Interface:
- New 'Schedules' tab in sync page
- Schedule cards with status badges
- Enable/disable with play/pause button
- Manual trigger with clock button
- Edit dialog with cron presets
- Create dialog for new schedules
- Real-time status (running, next run, last run)
- Success/failure indicators
- Error message alerts

Cron Features:
- Full cron expression support
- Validation before saving
- Common presets (daily, weekly, hourly)
- Next run time calculation
- Automatic schedule restart on config change

Monitoring:
- Last run timestamp
- Next run countdown (e.g., 'in 2h 15m')
- Success/failure status with icons
- Error messages for failed syncs
- Running indicator (animated badge)
- Schedule validity check

Dependencies:
- node-cron: ^3.0.3
- @types/node-cron: ^3.0.11

UI Components:
- Alert component added (components/ui/alert.tsx)
- Integrated into sync page tabs
- Responsive design

Documentation:
- Complete guide (docs/SCHEDULED_SYNCS.md)
- Cron expression reference
- Best practices
- Troubleshooting guide
- API reference
- Database schema

Use Cases:
1. Daily incremental sync for recent changes
2. Weekly full sync for data integrity
3. Custom schedules for specific needs
4. Off-peak hour automation
5. Backup for webhook failures

Benefits:
- No manual intervention required
- Consistent data freshness
- Flexible scheduling
- Easy monitoring
- Error tracking
- Manual override available

Next Steps:
1. Restart application to initialize scheduler
2. Navigate to Admin → Sync → Schedules tab
3. Enable default schedules or create custom ones
4. Monitor first runs for success
5. Adjust schedules as needed

Files Added/Modified:
- lib/services/sync-scheduler.ts (new)
- app/api/sync/schedules/route.ts (new)
- app/api/sync/schedules/[id]/route.ts (new)
- app/api/sync/schedules/[id]/trigger/route.ts (new)
- components/admin/SyncScheduler.tsx (new)
- components/ui/alert.tsx (new)
- app/admin/sync/page.tsx (modified - added Schedules tab)
- docs/SCHEDULED_SYNCS.md (new)
- package.json (node-cron added)
2026-01-26 10:24:58 -05:00

633 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
interface ScheduleConfig {
id: string;
name: string;
description: string;
cron_expression: string;
sync_type: 'incremental' | 'full';
years_back?: number;
is_enabled: boolean;
last_run?: string;
next_run?: string;
last_status?: 'success' | 'failed';
last_error?: string;
created_at: string;
updated_at: string;
}
interface ScheduleStatus {
config: ScheduleConfig;
isRunning: boolean;
isValid: boolean;
nextRun?: string;
}
export default function SyncScheduler() {
const [schedules, setSchedules] = useState<ScheduleStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingSchedule, setEditingSchedule] = useState<ScheduleConfig | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
// Form state
const [formData, setFormData] = useState({
id: '',
name: '',
description: '',
cron_expression: '',
sync_type: 'incremental' as 'incremental' | 'full',
years_back: 2,
is_enabled: false,
});
// Common cron presets
const cronPresets = [
{ label: 'Every day at 2 AM', value: '0 2 * * *' },
{ label: 'Every day at 3 AM', value: '0 3 * * *' },
{ label: 'Every Sunday at 3 AM', value: '0 3 * * 0' },
{ label: 'Every Monday at 2 AM', value: '0 2 * * 1' },
{ label: 'Every 6 hours', value: '0 */6 * * *' },
{ label: 'Every 12 hours', value: '0 */12 * * *' },
];
useEffect(() => {
fetchSchedules();
// Refresh every 30 seconds
const interval = setInterval(fetchSchedules, 30000);
return () => clearInterval(interval);
}, []);
const fetchSchedules = async () => {
try {
const response = await fetch('/api/sync/schedules');
const data = await response.json();
if (data.success) {
setSchedules(data.schedules);
setError(null);
} else {
setError(data.error || 'Failed to fetch schedules');
}
} catch (err) {
setError('Failed to fetch schedules');
console.error(err);
} finally {
setLoading(false);
}
};
const toggleSchedule = async (scheduleId: string, currentState: boolean) => {
try {
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_enabled: !currentState }),
});
const data = await response.json();
if (data.success) {
fetchSchedules();
} else {
alert(`Failed to toggle schedule: ${data.error}`);
}
} catch (err) {
alert('Failed to toggle schedule');
console.error(err);
}
};
const triggerSchedule = async (scheduleId: string) => {
if (!confirm('Are you sure you want to trigger this sync now?')) return;
try {
const response = await fetch(`/api/sync/schedules/${scheduleId}/trigger`, {
method: 'POST',
});
const data = await response.json();
if (data.success) {
alert('Sync triggered successfully');
fetchSchedules();
} else {
alert(`Failed to trigger sync: ${data.error}`);
}
} catch (err) {
alert('Failed to trigger sync');
console.error(err);
}
};
const deleteSchedule = async (scheduleId: string) => {
if (!confirm('Are you sure you want to delete this schedule?')) return;
try {
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
method: 'DELETE',
});
const data = await response.json();
if (data.success) {
fetchSchedules();
} else {
alert(`Failed to delete schedule: ${data.error}`);
}
} catch (err) {
alert('Failed to delete schedule');
console.error(err);
}
};
const openEditDialog = (schedule: ScheduleConfig) => {
setEditingSchedule(schedule);
setFormData({
id: schedule.id,
name: schedule.name,
description: schedule.description,
cron_expression: schedule.cron_expression,
sync_type: schedule.sync_type,
years_back: schedule.years_back || 2,
is_enabled: schedule.is_enabled,
});
setIsEditDialogOpen(true);
};
const openCreateDialog = () => {
setFormData({
id: '',
name: '',
description: '',
cron_expression: '0 2 * * *',
sync_type: 'incremental',
years_back: 2,
is_enabled: false,
});
setIsCreateDialogOpen(true);
};
const handleSubmit = async (isEdit: boolean) => {
try {
const url = isEdit
? `/api/sync/schedules/${formData.id}`
: '/api/sync/schedules';
const method = isEdit ? 'PATCH' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (data.success) {
setIsEditDialogOpen(false);
setIsCreateDialogOpen(false);
fetchSchedules();
} else {
alert(`Failed to ${isEdit ? 'update' : 'create'} schedule: ${data.error}`);
}
} catch (err) {
alert(`Failed to ${isEdit ? 'update' : 'create'} schedule`);
console.error(err);
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleString();
};
const formatNextRun = (dateString?: string) => {
if (!dateString) return 'Not scheduled';
const date = new Date(dateString);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Calculating...';
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `in ${days} day${days > 1 ? 's' : ''}`;
}
if (hours > 0) {
return `in ${hours}h ${minutes}m`;
}
return `in ${minutes}m`;
};
if (loading) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">Loading schedules...</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Sync Schedules</CardTitle>
<CardDescription>
Manage automatic sync schedules
</CardDescription>
</div>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
New Schedule
</Button>
</div>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{schedules.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No schedules configured. Create one to enable automatic syncs.
</div>
) : (
<div className="space-y-4">
{schedules.map((schedule) => (
<Card key={schedule.config.id} className="border-2">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg">{schedule.config.name}</h3>
<Badge variant={schedule.config.is_enabled ? 'default' : 'secondary'}>
{schedule.config.is_enabled ? 'Enabled' : 'Disabled'}
</Badge>
{schedule.isRunning && (
<Badge variant="outline" className="animate-pulse">
Running
</Badge>
)}
{!schedule.isValid && (
<Badge variant="destructive">Invalid Cron</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{schedule.config.description}
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Type</div>
<div className="font-medium capitalize">{schedule.config.sync_type}</div>
</div>
<div>
<div className="text-muted-foreground">Schedule</div>
<div className="font-mono text-xs">{schedule.config.cron_expression}</div>
</div>
<div>
<div className="text-muted-foreground">Next Run</div>
<div className="font-medium">
{schedule.config.is_enabled
? formatNextRun(schedule.config.next_run)
: 'Disabled'
}
</div>
</div>
<div>
<div className="text-muted-foreground">Last Run</div>
<div className="flex items-center gap-1">
{schedule.config.last_status === 'success' && (
<CheckCircle2 className="h-3 w-3 text-green-600" />
)}
{schedule.config.last_status === 'failed' && (
<XCircle className="h-3 w-3 text-red-600" />
)}
<span className="text-xs">{formatDate(schedule.config.last_run)}</span>
</div>
</div>
</div>
{schedule.config.last_error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{schedule.config.last_error}
</AlertDescription>
</Alert>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => toggleSchedule(schedule.config.id, schedule.config.is_enabled)}
disabled={schedule.isRunning}
>
{schedule.config.is_enabled ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => triggerSchedule(schedule.config.id)}
disabled={schedule.isRunning}
>
<Clock className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(schedule.config)}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => deleteSchedule(schedule.config.id)}
disabled={schedule.isRunning}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Schedule</DialogTitle>
<DialogDescription>
Configure a new automatic sync schedule
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="id">Schedule ID</Label>
<Input
id="id"
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
placeholder="e.g., daily-incremental"
/>
</div>
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Daily Incremental Sync"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of this schedule"
/>
</div>
<div>
<Label htmlFor="sync_type">Sync Type</Label>
<Select
value={formData.sync_type}
onValueChange={(value: 'incremental' | 'full') =>
setFormData({ ...formData, sync_type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="incremental">Incremental (last 24 hours)</SelectItem>
<SelectItem value="full">Full (all data)</SelectItem>
</SelectContent>
</Select>
</div>
{formData.sync_type === 'full' && (
<div>
<Label htmlFor="years_back">Years Back</Label>
<Input
id="years_back"
type="number"
min="1"
max="10"
value={formData.years_back}
onChange={(e) => setFormData({ ...formData, years_back: parseInt(e.target.value) })}
/>
</div>
)}
<div>
<Label htmlFor="cron_preset">Schedule Preset</Label>
<Select
value={formData.cron_expression}
onValueChange={(value) => setFormData({ ...formData, cron_expression: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a preset" />
</SelectTrigger>
<SelectContent>
{cronPresets.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label} ({preset.value})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="cron_expression">Cron Expression</Label>
<Input
id="cron_expression"
value={formData.cron_expression}
onChange={(e) => setFormData({ ...formData, cron_expression: e.target.value })}
placeholder="0 2 * * *"
className="font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
Format: minute hour day month weekday
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_enabled"
checked={formData.is_enabled}
onCheckedChange={(checked) => setFormData({ ...formData, is_enabled: checked })}
/>
<Label htmlFor="is_enabled">Enable schedule immediately</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={() => handleSubmit(false)}>
Create Schedule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Schedule</DialogTitle>
<DialogDescription>
Update schedule configuration
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit_name">Name</Label>
<Input
id="edit_name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<Label htmlFor="edit_description">Description</Label>
<Input
id="edit_description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div>
<Label htmlFor="edit_sync_type">Sync Type</Label>
<Select
value={formData.sync_type}
onValueChange={(value: 'incremental' | 'full') =>
setFormData({ ...formData, sync_type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="incremental">Incremental</SelectItem>
<SelectItem value="full">Full</SelectItem>
</SelectContent>
</Select>
</div>
{formData.sync_type === 'full' && (
<div>
<Label htmlFor="edit_years_back">Years Back</Label>
<Input
id="edit_years_back"
type="number"
min="1"
max="10"
value={formData.years_back}
onChange={(e) => setFormData({ ...formData, years_back: parseInt(e.target.value) })}
/>
</div>
)}
<div>
<Label htmlFor="edit_cron_preset">Schedule Preset</Label>
<Select
value={formData.cron_expression}
onValueChange={(value) => setFormData({ ...formData, cron_expression: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a preset" />
</SelectTrigger>
<SelectContent>
{cronPresets.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label} ({preset.value})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="edit_cron_expression">Cron Expression</Label>
<Input
id="edit_cron_expression"
value={formData.cron_expression}
onChange={(e) => setFormData({ ...formData, cron_expression: e.target.value })}
className="font-mono"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="edit_is_enabled"
checked={formData.is_enabled}
onCheckedChange={(checked) => setFormData({ ...formData, is_enabled: checked })}
/>
<Label htmlFor="edit_is_enabled">Enable schedule</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={() => handleSubmit(true)}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}