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)
This commit is contained in:
parent
a093f8787c
commit
f117210c9d
10 changed files with 5860 additions and 320 deletions
|
|
@ -9,10 +9,11 @@ import { useState, useEffect } from 'react';
|
|||
import SyncControlPanel from '@/components/admin/SyncControlPanel';
|
||||
import SyncDashboard from '@/components/admin/SyncDashboard';
|
||||
import SyncHistoryTable from '@/components/admin/SyncHistoryTable';
|
||||
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||
import { EntityType } from '@/lib/types/sync';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ArrowLeft, Home, Activity, History } from 'lucide-react';
|
||||
import { ArrowLeft, Home, Activity, History, Calendar } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminSyncPage() {
|
||||
|
|
@ -83,9 +84,9 @@ export default function AdminSyncPage() {
|
|||
isSyncing={isSyncing}
|
||||
/>
|
||||
|
||||
{/* Tabs for Sync Status and History */}
|
||||
{/* Tabs for Sync Status, History, and Schedules */}
|
||||
<Tabs defaultValue="status" className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-3">
|
||||
<TabsTrigger value="status" className="gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Sync Status
|
||||
|
|
@ -94,6 +95,10 @@ export default function AdminSyncPage() {
|
|||
<History className="h-4 w-4" />
|
||||
Sync History
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="schedules" className="gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Schedules
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="status" className="mt-6">
|
||||
|
|
@ -103,6 +108,10 @@ export default function AdminSyncPage() {
|
|||
<TabsContent value="history" className="mt-6">
|
||||
<SyncHistoryTable refreshKey={refreshKey} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="schedules" className="mt-6">
|
||||
<SyncScheduler />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
94
app/api/sync/schedules/[id]/route.ts
Normal file
94
app/api/sync/schedules/[id]/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Sync Schedule API (single schedule)
|
||||
* Manage individual sync schedules
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { syncScheduler } from '@/lib/services/sync-scheduler';
|
||||
|
||||
/**
|
||||
* GET /api/sync/schedules/[id]
|
||||
* Get a specific schedule
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const schedule = await syncScheduler.getSchedule(params.id);
|
||||
|
||||
if (!schedule) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Schedule not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[SCHEDULE API] Error fetching schedule:', errorMessage);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch schedule', details: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/sync/schedules/[id]
|
||||
* Update a schedule
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const schedule = await syncScheduler.updateSchedule(params.id, body);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[SCHEDULE API] Error updating schedule:', errorMessage);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update schedule', details: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/sync/schedules/[id]
|
||||
* Delete a schedule
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await syncScheduler.deleteSchedule(params.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Schedule deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[SCHEDULE API] Error deleting schedule:', errorMessage);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete schedule', details: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/api/sync/schedules/[id]/trigger/route.ts
Normal file
33
app/api/sync/schedules/[id]/trigger/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Trigger Schedule API
|
||||
* Manually trigger a scheduled sync
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { syncScheduler } from '@/lib/services/sync-scheduler';
|
||||
|
||||
/**
|
||||
* POST /api/sync/schedules/[id]/trigger
|
||||
* Manually trigger a schedule
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await syncScheduler.triggerSchedule(params.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Schedule triggered successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[TRIGGER API] Error triggering schedule:', errorMessage);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to trigger schedule', details: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
79
app/api/sync/schedules/route.ts
Normal file
79
app/api/sync/schedules/route.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Sync Schedules API
|
||||
* Manage scheduled automatic syncs
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { syncScheduler } from '@/lib/services/sync-scheduler';
|
||||
|
||||
/**
|
||||
* GET /api/sync/schedules
|
||||
* Get all sync schedules
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const schedules = await syncScheduler.getSchedules();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
schedules,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[SCHEDULES API] Error fetching schedules:', errorMessage);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch schedules', details: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sync/schedules
|
||||
* Create a new schedule
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.id || !body.name || !body.cron_expression || !body.sync_type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: id, name, cron_expression, sync_type' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate sync_type
|
||||
if (!['incremental', 'full'].includes(body.sync_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid sync_type. Must be "incremental" or "full"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await syncScheduler.createSchedule({
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
description: body.description || '',
|
||||
cron_expression: body.cron_expression,
|
||||
sync_type: body.sync_type,
|
||||
years_back: body.years_back,
|
||||
is_enabled: body.is_enabled ?? false,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[SCHEDULES API] Error creating schedule:', errorMessage);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create schedule', details: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
633
components/admin/SyncScheduler.tsx
Normal file
633
components/admin/SyncScheduler.tsx
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
59
components/ui/alert.tsx
Normal file
59
components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
616
docs/SCHEDULED_SYNCS.md
Normal file
616
docs/SCHEDULED_SYNCS.md
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
# Scheduled Syncs Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Pulse supports automatic scheduled syncs using node-cron. This allows you to configure daily, weekly, or custom sync schedules without manual intervention.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Configurable Schedules** - Create multiple sync schedules with different frequencies
|
||||
- ✅ **Cron Expressions** - Full cron syntax support for flexible scheduling
|
||||
- ✅ **Admin UI** - Manage schedules through the web interface
|
||||
- ✅ **Enable/Disable** - Toggle schedules on/off without deleting them
|
||||
- ✅ **Manual Trigger** - Run any schedule immediately for testing
|
||||
- ✅ **Status Monitoring** - View last run, next run, and success/failure status
|
||||
- ✅ **Error Tracking** - See error messages for failed syncs
|
||||
- ✅ **Incremental or Full** - Choose sync type per schedule
|
||||
|
||||
---
|
||||
|
||||
## Default Schedules
|
||||
|
||||
Two default schedules are created on first startup (disabled by default):
|
||||
|
||||
### **1. Daily Incremental Sync**
|
||||
- **Schedule:** Every day at 2 AM (`0 2 * * *`)
|
||||
- **Type:** Incremental (last 24 hours)
|
||||
- **Purpose:** Keep data up-to-date with daily changes
|
||||
- **Status:** Disabled (enable via UI)
|
||||
|
||||
### **2. Weekly Full Sync**
|
||||
- **Schedule:** Every Sunday at 3 AM (`0 3 * * 0`)
|
||||
- **Type:** Full (2 years back)
|
||||
- **Purpose:** Ensure data integrity with complete sync
|
||||
- **Status:** Disabled (enable via UI)
|
||||
|
||||
---
|
||||
|
||||
## Managing Schedules
|
||||
|
||||
### **Via Admin UI**
|
||||
|
||||
1. **Navigate to Sync Page**
|
||||
- Go to Admin → Sync
|
||||
- Click on "Schedules" tab
|
||||
|
||||
2. **View Schedules**
|
||||
- See all configured schedules
|
||||
- Check status (enabled/disabled, running, last run, next run)
|
||||
- View error messages for failed syncs
|
||||
|
||||
3. **Enable/Disable Schedule**
|
||||
- Click the Play/Pause button
|
||||
- Schedule starts/stops immediately
|
||||
|
||||
4. **Manually Trigger Schedule**
|
||||
- Click the Clock button
|
||||
- Sync runs immediately (doesn't affect schedule)
|
||||
|
||||
5. **Edit Schedule**
|
||||
- Click "Edit" button
|
||||
- Modify name, description, cron expression, sync type
|
||||
- Changes take effect immediately
|
||||
|
||||
6. **Create New Schedule**
|
||||
- Click "New Schedule" button
|
||||
- Fill in details:
|
||||
- **ID:** Unique identifier (e.g., `hourly-incremental`)
|
||||
- **Name:** Display name (e.g., `Hourly Incremental Sync`)
|
||||
- **Description:** What this schedule does
|
||||
- **Sync Type:** Incremental or Full
|
||||
- **Cron Expression:** When to run (use presets or custom)
|
||||
- **Enable:** Start immediately or leave disabled
|
||||
|
||||
7. **Delete Schedule**
|
||||
- Click Trash button
|
||||
- Confirm deletion
|
||||
- Schedule is permanently removed
|
||||
|
||||
### **Via API**
|
||||
|
||||
**Get All Schedules:**
|
||||
```bash
|
||||
curl http://localhost:3100/api/sync/schedules
|
||||
```
|
||||
|
||||
**Get Specific Schedule:**
|
||||
```bash
|
||||
curl http://localhost:3100/api/sync/schedules/daily-incremental
|
||||
```
|
||||
|
||||
**Create Schedule:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3100/api/sync/schedules \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "hourly-tickets",
|
||||
"name": "Hourly Ticket Sync",
|
||||
"description": "Sync tickets every hour",
|
||||
"cron_expression": "0 * * * *",
|
||||
"sync_type": "incremental",
|
||||
"is_enabled": true
|
||||
}'
|
||||
```
|
||||
|
||||
**Update Schedule:**
|
||||
```bash
|
||||
curl -X PATCH http://localhost:3100/api/sync/schedules/daily-incremental \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"is_enabled": true,
|
||||
"cron_expression": "0 3 * * *"
|
||||
}'
|
||||
```
|
||||
|
||||
**Delete Schedule:**
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3100/api/sync/schedules/hourly-tickets
|
||||
```
|
||||
|
||||
**Trigger Schedule Manually:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3100/api/sync/schedules/daily-incremental/trigger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron Expression Guide
|
||||
|
||||
Cron format: `minute hour day month weekday`
|
||||
|
||||
### **Common Patterns**
|
||||
|
||||
| Expression | Description |
|
||||
|------------|-------------|
|
||||
| `0 2 * * *` | Every day at 2 AM |
|
||||
| `0 3 * * 0` | Every Sunday at 3 AM |
|
||||
| `0 */6 * * *` | Every 6 hours |
|
||||
| `0 0 1 * *` | First day of every month at midnight |
|
||||
| `30 4 * * 1-5` | 4:30 AM on weekdays |
|
||||
| `0 */2 * * *` | Every 2 hours |
|
||||
| `15 14 1 * *` | 2:15 PM on the first of every month |
|
||||
|
||||
### **Field Values**
|
||||
|
||||
- **Minute:** 0-59
|
||||
- **Hour:** 0-23 (0 = midnight, 12 = noon)
|
||||
- **Day:** 1-31
|
||||
- **Month:** 1-12
|
||||
- **Weekday:** 0-7 (0 or 7 = Sunday)
|
||||
|
||||
### **Special Characters**
|
||||
|
||||
- `*` - Any value
|
||||
- `,` - List (e.g., `1,15` = 1st and 15th)
|
||||
- `-` - Range (e.g., `1-5` = Monday through Friday)
|
||||
- `/` - Step (e.g., `*/2` = every 2 units)
|
||||
|
||||
### **Examples**
|
||||
|
||||
```
|
||||
0 2 * * * # 2:00 AM every day
|
||||
0 3 * * 0 # 3:00 AM every Sunday
|
||||
0 */4 * * * # Every 4 hours
|
||||
30 9 * * 1-5 # 9:30 AM on weekdays
|
||||
0 0 1,15 * * # Midnight on 1st and 15th
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Schedules
|
||||
|
||||
### **Small Organization (< 100 tickets/day)**
|
||||
|
||||
```
|
||||
Daily Incremental: 0 2 * * * (2 AM daily)
|
||||
Weekly Full: 0 3 * * 0 (3 AM Sunday)
|
||||
```
|
||||
|
||||
### **Medium Organization (100-500 tickets/day)**
|
||||
|
||||
```
|
||||
Incremental: 0 */6 * * * (Every 6 hours)
|
||||
Weekly Full: 0 3 * * 0 (3 AM Sunday)
|
||||
```
|
||||
|
||||
### **Large Organization (> 500 tickets/day)**
|
||||
|
||||
```
|
||||
Incremental: 0 */2 * * * (Every 2 hours)
|
||||
Daily Full: 0 3 * * * (3 AM daily)
|
||||
```
|
||||
|
||||
### **With Webhooks**
|
||||
|
||||
If using webhooks for real-time updates:
|
||||
|
||||
```
|
||||
Daily Incremental: 0 2 * * * (Backup for missed webhooks)
|
||||
Weekly Full: 0 3 * * 0 (Data integrity check)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### **1. Stagger Schedules**
|
||||
|
||||
Don't run multiple syncs at the same time:
|
||||
```
|
||||
Daily Incremental: 0 2 * * * (2 AM)
|
||||
Weekly Full: 0 3 * * 0 (3 AM Sunday)
|
||||
```
|
||||
|
||||
### **2. Off-Peak Hours**
|
||||
|
||||
Schedule syncs during low-usage periods:
|
||||
- ✅ 2-4 AM (recommended)
|
||||
- ✅ Late evening (10 PM - midnight)
|
||||
- ❌ Business hours (9 AM - 5 PM)
|
||||
|
||||
### **3. Start Disabled**
|
||||
|
||||
Create new schedules disabled, test manually first:
|
||||
```json
|
||||
{
|
||||
"is_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Then enable after verifying it works.
|
||||
|
||||
### **4. Monitor First Runs**
|
||||
|
||||
After enabling a schedule:
|
||||
1. Wait for first scheduled run
|
||||
2. Check sync history for success
|
||||
3. Review any error messages
|
||||
4. Adjust schedule if needed
|
||||
|
||||
### **5. Incremental + Full Strategy**
|
||||
|
||||
Combine both for best results:
|
||||
- **Incremental:** Daily or more frequent (fast, recent changes)
|
||||
- **Full:** Weekly or monthly (slow, ensures data integrity)
|
||||
|
||||
### **6. Test with Manual Trigger**
|
||||
|
||||
Before enabling a schedule:
|
||||
1. Create schedule (disabled)
|
||||
2. Click "Trigger" button to run manually
|
||||
3. Verify sync completes successfully
|
||||
4. Enable schedule
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### **Schedule Status**
|
||||
|
||||
Each schedule shows:
|
||||
- **Enabled/Disabled** - Current state
|
||||
- **Running** - Currently executing (animated badge)
|
||||
- **Next Run** - When it will run next (e.g., "in 2h 15m")
|
||||
- **Last Run** - When it last executed
|
||||
- **Last Status** - Success ✓ or Failed ✗
|
||||
- **Error Message** - Details if failed
|
||||
|
||||
### **Check Logs**
|
||||
|
||||
View application logs for scheduler activity:
|
||||
|
||||
```bash
|
||||
# Docker logs
|
||||
docker logs pulse-app | grep SCHEDULER
|
||||
|
||||
# Recent scheduler events
|
||||
docker logs pulse-app --tail 100 | grep SCHEDULER
|
||||
```
|
||||
|
||||
### **Database Queries**
|
||||
|
||||
```sql
|
||||
-- View all schedules
|
||||
SELECT * FROM sync_schedules ORDER BY id;
|
||||
|
||||
-- View enabled schedules
|
||||
SELECT id, name, cron_expression, next_run
|
||||
FROM sync_schedules
|
||||
WHERE is_enabled = true;
|
||||
|
||||
-- View failed schedules
|
||||
SELECT id, name, last_run, last_error
|
||||
FROM sync_schedules
|
||||
WHERE last_status = 'failed';
|
||||
|
||||
-- View schedule history
|
||||
SELECT
|
||||
s.name,
|
||||
s.last_run,
|
||||
s.last_status,
|
||||
h.records_added,
|
||||
h.records_updated
|
||||
FROM sync_schedules s
|
||||
LEFT JOIN sync_history h ON h.started_at = s.last_run
|
||||
WHERE s.last_run IS NOT NULL
|
||||
ORDER BY s.last_run DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### **Schedule Not Running**
|
||||
|
||||
**Check 1: Is it enabled?**
|
||||
- Look for "Enabled" badge
|
||||
- If disabled, click Play button
|
||||
|
||||
**Check 2: Is cron expression valid?**
|
||||
- Look for "Invalid Cron" badge
|
||||
- Edit schedule and fix expression
|
||||
|
||||
**Check 3: Check next run time**
|
||||
- Ensure next run is in the future
|
||||
- If "Calculating...", wait a moment and refresh
|
||||
|
||||
**Check 4: Application running?**
|
||||
- Scheduler only works when app is running
|
||||
- Check `docker ps` or process status
|
||||
|
||||
**Check 5: Check logs**
|
||||
```bash
|
||||
docker logs pulse-app | grep "SCHEDULER.*daily-incremental"
|
||||
```
|
||||
|
||||
### **Schedule Failing**
|
||||
|
||||
**Check Error Message:**
|
||||
- View in UI under schedule card
|
||||
- Shows last error from failed sync
|
||||
|
||||
**Common Errors:**
|
||||
|
||||
1. **"A sync operation is already in progress"**
|
||||
- Another sync is running
|
||||
- Wait for it to complete
|
||||
- Consider adjusting schedule times
|
||||
|
||||
2. **"Failed to connect to Autotask API"**
|
||||
- Check API credentials
|
||||
- Verify network connectivity
|
||||
- Check Autotask API status
|
||||
|
||||
3. **"Database connection error"**
|
||||
- Check PostgreSQL is running
|
||||
- Verify database credentials
|
||||
- Check disk space
|
||||
|
||||
**View Sync History:**
|
||||
- Go to "Sync History" tab
|
||||
- Filter by entity type
|
||||
- Check error details
|
||||
|
||||
### **Schedule Running Too Long**
|
||||
|
||||
If a sync takes longer than expected:
|
||||
|
||||
1. **Check current sync status:**
|
||||
```bash
|
||||
curl http://localhost:3100/api/sync/status
|
||||
```
|
||||
|
||||
2. **Review sync history:**
|
||||
- Look at duration of previous syncs
|
||||
- Identify slow entities
|
||||
|
||||
3. **Consider splitting:**
|
||||
- Create separate schedules for slow entities
|
||||
- Run them at different times
|
||||
|
||||
### **Missed Schedules**
|
||||
|
||||
If application was down during scheduled time:
|
||||
- Schedule will NOT run retroactively
|
||||
- Next run will be at next scheduled time
|
||||
- Consider manual trigger if data is critical
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### **sync_schedules Table**
|
||||
|
||||
```sql
|
||||
CREATE TABLE sync_schedules (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
cron_expression VARCHAR(50) NOT NULL,
|
||||
sync_type VARCHAR(20) NOT NULL, -- 'incremental' or 'full'
|
||||
years_back INTEGER DEFAULT 2,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_run TIMESTAMP,
|
||||
next_run TIMESTAMP,
|
||||
last_status VARCHAR(20), -- 'success' or 'failed'
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### **Environment Variables**
|
||||
|
||||
Currently, schedules are managed via database and UI. Future versions may support:
|
||||
|
||||
```env
|
||||
# Example (not yet implemented)
|
||||
ENABLE_SCHEDULED_SYNC=true
|
||||
DEFAULT_INCREMENTAL_CRON=0 2 * * *
|
||||
DEFAULT_FULL_CRON=0 3 * * 0
|
||||
```
|
||||
|
||||
### **Custom Schedules**
|
||||
|
||||
Create specialized schedules for specific needs:
|
||||
|
||||
**High-Priority Entities:**
|
||||
```json
|
||||
{
|
||||
"id": "hourly-tickets",
|
||||
"name": "Hourly Ticket Sync",
|
||||
"cron_expression": "0 * * * *",
|
||||
"sync_type": "incremental"
|
||||
}
|
||||
```
|
||||
|
||||
**Monthly Reports:**
|
||||
```json
|
||||
{
|
||||
"id": "monthly-full",
|
||||
"name": "Monthly Full Sync",
|
||||
"cron_expression": "0 4 1 * *",
|
||||
"sync_type": "full",
|
||||
"years_back": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Business Hours Only:**
|
||||
```json
|
||||
{
|
||||
"id": "business-hours",
|
||||
"name": "Business Hours Sync",
|
||||
"cron_expression": "0 9-17 * * 1-5",
|
||||
"sync_type": "incremental"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### **Concurrent Syncs**
|
||||
|
||||
- Scheduler prevents concurrent runs of the SAME schedule
|
||||
- Different schedules CAN run concurrently
|
||||
- Sync service prevents multiple syncs system-wide
|
||||
|
||||
### **Resource Usage**
|
||||
|
||||
**During Sync:**
|
||||
- CPU: Moderate (data processing)
|
||||
- Memory: Moderate (batch processing)
|
||||
- Network: High (API calls)
|
||||
- Database: Moderate (bulk inserts)
|
||||
|
||||
**Recommendations:**
|
||||
- Schedule during off-peak hours
|
||||
- Monitor server resources
|
||||
- Adjust frequency based on load
|
||||
|
||||
### **API Rate Limits**
|
||||
|
||||
Autotask has rate limits:
|
||||
- Be mindful of sync frequency
|
||||
- Incremental syncs use fewer API calls
|
||||
- Full syncs can hit rate limits on large datasets
|
||||
|
||||
---
|
||||
|
||||
## Migration from Manual Syncs
|
||||
|
||||
### **Step 1: Document Current Process**
|
||||
- How often do you sync manually?
|
||||
- Which entities do you sync?
|
||||
- What time of day?
|
||||
|
||||
### **Step 2: Create Equivalent Schedules**
|
||||
- Daily manual sync → Daily incremental schedule
|
||||
- Weekly manual sync → Weekly full schedule
|
||||
|
||||
### **Step 3: Test Schedules**
|
||||
- Create schedules (disabled)
|
||||
- Trigger manually to test
|
||||
- Verify results in sync history
|
||||
|
||||
### **Step 4: Enable Gradually**
|
||||
- Enable one schedule at a time
|
||||
- Monitor for 1 week
|
||||
- Adjust as needed
|
||||
|
||||
### **Step 5: Stop Manual Syncs**
|
||||
- Once confident in automated syncs
|
||||
- Keep manual option for emergencies
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### **Access Control**
|
||||
|
||||
Currently, schedule management requires:
|
||||
- Access to admin interface
|
||||
- No authentication implemented yet
|
||||
|
||||
**Future considerations:**
|
||||
- Role-based access control
|
||||
- Audit logging for schedule changes
|
||||
- API key authentication
|
||||
|
||||
### **Schedule Validation**
|
||||
|
||||
- Cron expressions validated before saving
|
||||
- Invalid expressions rejected
|
||||
- Prevents malicious or broken schedules
|
||||
|
||||
---
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### **Backup Schedules**
|
||||
|
||||
Export schedule configuration:
|
||||
|
||||
```sql
|
||||
-- Export schedules
|
||||
COPY (
|
||||
SELECT id, name, description, cron_expression, sync_type, years_back, is_enabled
|
||||
FROM sync_schedules
|
||||
) TO '/tmp/schedules_backup.csv' CSV HEADER;
|
||||
```
|
||||
|
||||
### **Restore Schedules**
|
||||
|
||||
Import schedule configuration:
|
||||
|
||||
```sql
|
||||
-- Import schedules
|
||||
COPY sync_schedules (id, name, description, cron_expression, sync_type, years_back, is_enabled)
|
||||
FROM '/tmp/schedules_backup.csv' CSV HEADER;
|
||||
```
|
||||
|
||||
Or use API to recreate schedules.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I have multiple schedules running at once?**
|
||||
A: Different schedules can run concurrently, but the sync service prevents multiple syncs system-wide. If one schedule is running, others will wait.
|
||||
|
||||
**Q: What happens if the app restarts during a scheduled sync?**
|
||||
A: The sync will be interrupted. The schedule will run again at the next scheduled time.
|
||||
|
||||
**Q: Can I change a schedule while it's running?**
|
||||
A: Yes, but changes won't affect the current run. They'll apply to the next scheduled run.
|
||||
|
||||
**Q: Do schedules run if the app is stopped?**
|
||||
A: No. Schedules only run when the application is running. Consider using systemd or Docker restart policies.
|
||||
|
||||
**Q: Can I schedule specific entities?**
|
||||
A: Not yet. Schedules sync all entities. This may be added in a future version.
|
||||
|
||||
**Q: What timezone are schedules in?**
|
||||
A: Schedules use the server's timezone. Check with `date` command on the server.
|
||||
|
||||
**Q: Can I get notifications when syncs fail?**
|
||||
A: Not yet. Check the UI or logs. Notifications may be added in a future version.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Scheduled syncs provide:**
|
||||
- ✅ Automatic data synchronization
|
||||
- ✅ Flexible scheduling with cron expressions
|
||||
- ✅ Easy management via admin UI
|
||||
- ✅ Status monitoring and error tracking
|
||||
- ✅ Manual trigger for testing
|
||||
- ✅ Multiple schedules for different needs
|
||||
|
||||
**Recommended setup:**
|
||||
1. Enable daily incremental sync (2 AM)
|
||||
2. Enable weekly full sync (Sunday 3 AM)
|
||||
3. Monitor for first week
|
||||
4. Adjust as needed based on your usage
|
||||
|
||||
**Best combined with:**
|
||||
- Webhooks for real-time updates
|
||||
- Manual syncs for immediate needs
|
||||
- Regular monitoring of sync history
|
||||
533
lib/services/sync-scheduler.ts
Normal file
533
lib/services/sync-scheduler.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
/**
|
||||
* Sync Scheduler Service
|
||||
* Manages scheduled automatic syncs using node-cron
|
||||
*/
|
||||
|
||||
import cron, { ScheduledTask } from 'node-cron';
|
||||
import { syncService } from './sync-service';
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
next_run?: Date;
|
||||
last_status?: 'success' | 'failed';
|
||||
last_error?: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface ScheduleStatus {
|
||||
config: ScheduleConfig;
|
||||
isRunning: boolean;
|
||||
isValid: boolean;
|
||||
nextRun?: Date;
|
||||
}
|
||||
|
||||
class SyncScheduler {
|
||||
private tasks: Map<string, ScheduledTask> = new Map();
|
||||
private runningJobs: Set<string> = new Set();
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the scheduler and load schedules from database
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
console.log('[SCHEDULER] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SCHEDULER] Initializing sync scheduler...');
|
||||
|
||||
try {
|
||||
// Create schedules table if it doesn't exist
|
||||
await this.createSchedulesTable();
|
||||
|
||||
// Create default schedules if none exist
|
||||
await this.createDefaultSchedules();
|
||||
|
||||
// Load and start all enabled schedules
|
||||
await this.loadSchedules();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[SCHEDULER] Sync scheduler initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[SCHEDULER] Failed to initialize:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create schedules table
|
||||
*/
|
||||
private async createSchedulesTable(): Promise<void> {
|
||||
const query = `
|
||||
CREATE TABLE IF NOT EXISTS sync_schedules (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
cron_expression VARCHAR(50) NOT NULL,
|
||||
sync_type VARCHAR(20) NOT NULL CHECK (sync_type IN ('incremental', 'full')),
|
||||
years_back INTEGER DEFAULT 2,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_run TIMESTAMP,
|
||||
next_run TIMESTAMP,
|
||||
last_status VARCHAR(20),
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_schedules_enabled ON sync_schedules(is_enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_schedules_next_run ON sync_schedules(next_run);
|
||||
`;
|
||||
|
||||
await postgresClient.query(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default schedules if none exist
|
||||
*/
|
||||
private async createDefaultSchedules(): Promise<void> {
|
||||
const countResult = await postgresClient.query(
|
||||
'SELECT COUNT(*) as count FROM sync_schedules'
|
||||
);
|
||||
|
||||
if (parseInt(countResult.rows[0].count) > 0) {
|
||||
console.log('[SCHEDULER] Schedules already exist, skipping defaults');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SCHEDULER] Creating default schedules...');
|
||||
|
||||
const defaultSchedules = [
|
||||
{
|
||||
id: 'daily-incremental',
|
||||
name: 'Daily Incremental Sync',
|
||||
description: 'Syncs changes from the last 24 hours every day at 2 AM',
|
||||
cron_expression: '0 2 * * *',
|
||||
sync_type: 'incremental',
|
||||
is_enabled: false, // Disabled by default - user must enable
|
||||
},
|
||||
{
|
||||
id: 'weekly-full',
|
||||
name: 'Weekly Full Sync',
|
||||
description: 'Full sync of all data every Sunday at 3 AM',
|
||||
cron_expression: '0 3 * * 0',
|
||||
sync_type: 'full',
|
||||
years_back: 2,
|
||||
is_enabled: false, // Disabled by default - user must enable
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, years_back, is_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
schedule.id,
|
||||
schedule.name,
|
||||
schedule.description,
|
||||
schedule.cron_expression,
|
||||
schedule.sync_type,
|
||||
schedule.years_back || null,
|
||||
schedule.is_enabled,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[SCHEDULER] Default schedules created');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all schedules from database and start enabled ones
|
||||
*/
|
||||
async loadSchedules(): Promise<void> {
|
||||
console.log('[SCHEDULER] Loading schedules from database...');
|
||||
|
||||
const result = await postgresClient.query(
|
||||
'SELECT * FROM sync_schedules ORDER BY id'
|
||||
);
|
||||
|
||||
const schedules = result.rows as ScheduleConfig[];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.is_enabled) {
|
||||
this.startSchedule(schedule);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SCHEDULER] Loaded ${schedules.length} schedules (${this.tasks.size} active)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a schedule
|
||||
*/
|
||||
private startSchedule(config: ScheduleConfig): void {
|
||||
// Stop existing task if any
|
||||
this.stopSchedule(config.id);
|
||||
|
||||
// Validate cron expression
|
||||
if (!cron.validate(config.cron_expression)) {
|
||||
console.error(`[SCHEDULER] Invalid cron expression for ${config.id}: ${config.cron_expression}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SCHEDULER] Starting schedule: ${config.name} (${config.cron_expression})`);
|
||||
|
||||
const task = cron.schedule(config.cron_expression, async () => {
|
||||
await this.executeScheduledSync(config);
|
||||
});
|
||||
|
||||
this.tasks.set(config.id, task);
|
||||
|
||||
// Calculate and update next run time
|
||||
this.updateNextRunTime(config.id, config.cron_expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a schedule
|
||||
*/
|
||||
private stopSchedule(scheduleId: string): void {
|
||||
const task = this.tasks.get(scheduleId);
|
||||
if (task) {
|
||||
task.stop();
|
||||
this.tasks.delete(scheduleId);
|
||||
console.log(`[SCHEDULER] Stopped schedule: ${scheduleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a scheduled sync
|
||||
*/
|
||||
private async executeScheduledSync(config: ScheduleConfig): Promise<void> {
|
||||
// Prevent concurrent runs of the same schedule
|
||||
if (this.runningJobs.has(config.id)) {
|
||||
console.log(`[SCHEDULER] Schedule ${config.id} is already running, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.runningJobs.add(config.id);
|
||||
const startTime = new Date();
|
||||
|
||||
console.log(`[SCHEDULER] Executing scheduled sync: ${config.name}`);
|
||||
|
||||
try {
|
||||
// Update last_run timestamp
|
||||
await postgresClient.query(
|
||||
'UPDATE sync_schedules SET last_run = NOW() WHERE id = $1',
|
||||
[config.id]
|
||||
);
|
||||
|
||||
// Execute the sync
|
||||
if (config.sync_type === 'incremental') {
|
||||
await syncService.startIncrementalSync('scheduled');
|
||||
} else {
|
||||
await syncService.startFullSync('scheduled', config.years_back || 2);
|
||||
}
|
||||
|
||||
// Update success status
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_schedules
|
||||
SET last_status = 'success', last_error = NULL, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[config.id]
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime.getTime();
|
||||
console.log(`[SCHEDULER] Scheduled sync ${config.name} completed successfully in ${duration}ms`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[SCHEDULER] Scheduled sync ${config.name} failed:`, errorMessage);
|
||||
|
||||
// Update failure status
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_schedules
|
||||
SET last_status = 'failed', last_error = $2, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[config.id, errorMessage]
|
||||
);
|
||||
} finally {
|
||||
this.runningJobs.delete(config.id);
|
||||
|
||||
// Update next run time
|
||||
this.updateNextRunTime(config.id, config.cron_expression);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and update next run time
|
||||
*/
|
||||
private async updateNextRunTime(scheduleId: string, cronExpression: string): Promise<void> {
|
||||
try {
|
||||
const nextRun = this.getNextRunTime(cronExpression);
|
||||
if (nextRun) {
|
||||
await postgresClient.query(
|
||||
'UPDATE sync_schedules SET next_run = $2 WHERE id = $1',
|
||||
[scheduleId, nextRun]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SCHEDULER] Failed to update next run time for ${scheduleId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next run time for a cron expression
|
||||
*/
|
||||
private getNextRunTime(cronExpression: string): Date | null {
|
||||
try {
|
||||
// Parse cron expression and calculate next run
|
||||
// This is a simplified calculation - node-cron doesn't expose this directly
|
||||
const parts = cronExpression.split(' ');
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
|
||||
// Simple calculation for common patterns
|
||||
if (minute !== '*') next.setMinutes(parseInt(minute));
|
||||
if (hour !== '*') next.setHours(parseInt(hour));
|
||||
|
||||
// If time has passed today, move to next occurrence
|
||||
if (next <= now) {
|
||||
if (dayOfWeek !== '*') {
|
||||
// Weekly schedule
|
||||
const targetDay = parseInt(dayOfWeek);
|
||||
const currentDay = next.getDay();
|
||||
const daysToAdd = targetDay >= currentDay ? targetDay - currentDay : 7 - currentDay + targetDay;
|
||||
next.setDate(next.getDate() + daysToAdd);
|
||||
} else {
|
||||
// Daily schedule
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
next.setSeconds(0);
|
||||
next.setMilliseconds(0);
|
||||
|
||||
return next;
|
||||
} catch (error) {
|
||||
console.error('[SCHEDULER] Error calculating next run time:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all schedules
|
||||
*/
|
||||
async getSchedules(): Promise<ScheduleStatus[]> {
|
||||
const result = await postgresClient.query(
|
||||
'SELECT * FROM sync_schedules ORDER BY id'
|
||||
);
|
||||
|
||||
const schedules = result.rows as ScheduleConfig[];
|
||||
|
||||
return schedules.map(config => ({
|
||||
config,
|
||||
isRunning: this.runningJobs.has(config.id),
|
||||
isValid: cron.validate(config.cron_expression),
|
||||
nextRun: config.next_run || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific schedule
|
||||
*/
|
||||
async getSchedule(scheduleId: string): Promise<ScheduleStatus | null> {
|
||||
const result = await postgresClient.query(
|
||||
'SELECT * FROM sync_schedules WHERE id = $1',
|
||||
[scheduleId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const config = result.rows[0] as ScheduleConfig;
|
||||
|
||||
return {
|
||||
config,
|
||||
isRunning: this.runningJobs.has(config.id),
|
||||
isValid: cron.validate(config.cron_expression),
|
||||
nextRun: config.next_run || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a schedule
|
||||
*/
|
||||
async updateSchedule(
|
||||
scheduleId: string,
|
||||
updates: Partial<Pick<ScheduleConfig, 'name' | 'description' | 'cron_expression' | 'sync_type' | 'years_back' | 'is_enabled'>>
|
||||
): Promise<ScheduleConfig> {
|
||||
// Validate cron expression if provided
|
||||
if (updates.cron_expression && !cron.validate(updates.cron_expression)) {
|
||||
throw new Error(`Invalid cron expression: ${updates.cron_expression}`);
|
||||
}
|
||||
|
||||
// Build update query
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
fields.push(`name = $${paramIndex++}`);
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
fields.push(`description = $${paramIndex++}`);
|
||||
values.push(updates.description);
|
||||
}
|
||||
if (updates.cron_expression !== undefined) {
|
||||
fields.push(`cron_expression = $${paramIndex++}`);
|
||||
values.push(updates.cron_expression);
|
||||
}
|
||||
if (updates.sync_type !== undefined) {
|
||||
fields.push(`sync_type = $${paramIndex++}`);
|
||||
values.push(updates.sync_type);
|
||||
}
|
||||
if (updates.years_back !== undefined) {
|
||||
fields.push(`years_back = $${paramIndex++}`);
|
||||
values.push(updates.years_back);
|
||||
}
|
||||
if (updates.is_enabled !== undefined) {
|
||||
fields.push(`is_enabled = $${paramIndex++}`);
|
||||
values.push(updates.is_enabled);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(scheduleId);
|
||||
|
||||
const query = `
|
||||
UPDATE sync_schedules
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await postgresClient.query(query, values);
|
||||
const config = result.rows[0] as ScheduleConfig;
|
||||
|
||||
// Restart the schedule if it's enabled
|
||||
if (config.is_enabled) {
|
||||
this.startSchedule(config);
|
||||
} else {
|
||||
this.stopSchedule(scheduleId);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new schedule
|
||||
*/
|
||||
async createSchedule(
|
||||
schedule: Omit<ScheduleConfig, 'created_at' | 'updated_at' | 'last_run' | 'next_run' | 'last_status' | 'last_error'>
|
||||
): Promise<ScheduleConfig> {
|
||||
// Validate cron expression
|
||||
if (!cron.validate(schedule.cron_expression)) {
|
||||
throw new Error(`Invalid cron expression: ${schedule.cron_expression}`);
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, years_back, is_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await postgresClient.query(query, [
|
||||
schedule.id,
|
||||
schedule.name,
|
||||
schedule.description,
|
||||
schedule.cron_expression,
|
||||
schedule.sync_type,
|
||||
schedule.years_back || null,
|
||||
schedule.is_enabled,
|
||||
]);
|
||||
|
||||
const config = result.rows[0] as ScheduleConfig;
|
||||
|
||||
// Start the schedule if enabled
|
||||
if (config.is_enabled) {
|
||||
this.startSchedule(config);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a schedule
|
||||
*/
|
||||
async deleteSchedule(scheduleId: string): Promise<void> {
|
||||
// Stop the schedule first
|
||||
this.stopSchedule(scheduleId);
|
||||
|
||||
// Delete from database
|
||||
await postgresClient.query(
|
||||
'DELETE FROM sync_schedules WHERE id = $1',
|
||||
[scheduleId]
|
||||
);
|
||||
|
||||
console.log(`[SCHEDULER] Deleted schedule: ${scheduleId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a schedule
|
||||
*/
|
||||
async triggerSchedule(scheduleId: string): Promise<void> {
|
||||
const result = await postgresClient.query(
|
||||
'SELECT * FROM sync_schedules WHERE id = $1',
|
||||
[scheduleId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`Schedule not found: ${scheduleId}`);
|
||||
}
|
||||
|
||||
const config = result.rows[0] as ScheduleConfig;
|
||||
await this.executeScheduledSync(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
*/
|
||||
validateCronExpression(expression: string): boolean {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the scheduler
|
||||
*/
|
||||
shutdown(): void {
|
||||
console.log('[SCHEDULER] Shutting down sync scheduler...');
|
||||
|
||||
for (const [id, task] of this.tasks.entries()) {
|
||||
task.stop();
|
||||
console.log(`[SCHEDULER] Stopped schedule: ${id}`);
|
||||
}
|
||||
|
||||
this.tasks.clear();
|
||||
this.runningJobs.clear();
|
||||
this.initialized = false;
|
||||
|
||||
console.log('[SCHEDULER] Sync scheduler shut down');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const syncScheduler = new SyncScheduler();
|
||||
|
||||
// Initialize on server startup (only in Node.js environment)
|
||||
if (typeof window === 'undefined') {
|
||||
syncScheduler.initialize().catch(error => {
|
||||
console.error('[SCHEDULER] Failed to initialize on startup:', error);
|
||||
});
|
||||
}
|
||||
4068
package-lock.json
generated
4068
package-lock.json
generated
File diff suppressed because it is too large
Load diff
50
package.json
50
package.json
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/cli": "^1.4.10",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
|
@ -16,45 +17,50 @@
|
|||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@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",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"better-auth": "^1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"ioredis": "^5.8.2",
|
||||
"lucide-react": "^0.548.0",
|
||||
"next": "16.0.0",
|
||||
"ioredis": "^5.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.12",
|
||||
"pg": "^8.11.0",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"redis": "^5.9.0",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"redis": "^5.10.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.10.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^20.19.27",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue