wulf-pulse/components/admin/SyncScheduler.tsx
lorentz 8f955a0ff9 feat(07.1-05): user-tz on shared client components
- DetailModal: thread tz through resolveLabel(...) module helper +
  default export's 3 inline date/time calls.
- IntegrationStatusTabs: thread tz through fmtDate helper +
  VeeamTab sub-component prop.
- SyncScheduler: thread tz into closure-scoped formatDate helper.
- audit-log-table, user-table, user-sessions, active-sessions: inline
  toLocale calls in component body.
- analysis-view: useUserTimezone in AnalysisView; thread tz into 4
  toLocaleString calls.
- resolution-trend, volume-trend (recharts): module-scope fmtDate(iso)
  → fmtDate(iso, tz); useUserTimezone in named export; thread tz into
  axis tickFormatter + tooltip labelFormatter.
- ticket-detail-modal: thread tz into formatDate arrow inside
  TicketDetailModal.
- TimelineView: useUserTimezone; thread tz into 4 toLocale*String calls
  (hour/day/month/event-time formatters).
- ScoreCard: useUserTimezone in AggregateScoreCard; thread tz into the
  date-range latest call.
- addigy-tab: useUserTimezone in AddigyTab; thread tz into 2 inline calls.
- activity-sparkline: module-scope fmtHour(iso) → fmtHour(iso, tz);
  useUserTimezone in ActivitySparkline; update 3 callsites in title/aria.
- compliance-detail-table: thread tz from ComplianceDetailTable into
  ContractCoverageModal sub-component (2 inline date calls).
- company-backup-detail: module-scope formatDate(d) → formatDate(d, tz);
  useUserTimezone in CompanyBackupDetail; update 3 callsites.

Migrates 31 of 81 audit leak callsites.
2026-05-07 08:43:27 -04:00

656 lines
23 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, RefreshCw } from 'lucide-react';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
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 tz = useUserTimezone();
const [schedules, setSchedules] = useState<ScheduleStatus[]>([]);
const [reloading, setReloading] = useState(false);
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 reloadSchedules = async () => {
setReloading(true);
try {
const res = await fetch('/api/sync/schedules/reload', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
await fetchSchedules();
} catch (err) {
alert(`Reload failed: ${err instanceof Error ? err.message : err}`);
} finally {
setReloading(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(undefined, { timeZone: tz });
};
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>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={reloadSchedules} disabled={reloading}>
<RefreshCw className={`h-4 w-4 mr-2 ${reloading ? 'animate-spin' : ''}`} />
{reloading ? 'Reloading…' : 'Reload from DB'}
</Button>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
New Schedule
</Button>
</div>
</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>
);
}