242 lines
7.8 KiB
Svelte
242 lines
7.8 KiB
Svelte
<script lang="ts">
|
|
import { Timer, Play, CheckCircle, XCircle, Clock, RefreshCw, AlertTriangle } from 'lucide-svelte';
|
|
import { enhance } from '$app/forms';
|
|
import { invalidateAll } from '$app/navigation';
|
|
|
|
let { data, form } = $props();
|
|
const cronLogs = $derived(data.cronLogs);
|
|
|
|
let runningJob = $state<string | null>(null);
|
|
|
|
const cronJobs = [
|
|
{
|
|
id: 'dues-reminders',
|
|
name: 'Dues Reminders',
|
|
description: 'Send payment reminders to members with upcoming or overdue dues',
|
|
schedule: 'Daily at 9:00 AM',
|
|
action: '?/runDuesReminders'
|
|
},
|
|
{
|
|
id: 'event-reminders',
|
|
name: 'Event Reminders',
|
|
description: 'Send reminder emails 24 hours before events',
|
|
schedule: 'Hourly',
|
|
action: '?/runEventReminders'
|
|
}
|
|
];
|
|
|
|
function getStatusBadge(status: string) {
|
|
switch (status) {
|
|
case 'completed':
|
|
return { icon: CheckCircle, color: 'text-green-600', bg: 'bg-green-50', label: 'Completed' };
|
|
case 'failed':
|
|
return { icon: XCircle, color: 'text-red-600', bg: 'bg-red-50', label: 'Failed' };
|
|
case 'running':
|
|
return { icon: RefreshCw, color: 'text-blue-600', bg: 'bg-blue-50', label: 'Running' };
|
|
default:
|
|
return { icon: Clock, color: 'text-slate-500', bg: 'bg-slate-50', label: status };
|
|
}
|
|
}
|
|
|
|
function formatDuration(ms: number | null): string {
|
|
if (!ms) return '-';
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function getLastRun(jobName: string) {
|
|
return cronLogs.find((log: any) => log.job_name === jobName);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Cron Monitoring | Monaco USA</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-900">Cron Job Monitoring</h1>
|
|
<p class="text-slate-500">Monitor and manually trigger scheduled tasks</p>
|
|
</div>
|
|
|
|
{#if form?.error}
|
|
<div class="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
|
<div class="flex items-center gap-2">
|
|
<AlertTriangle class="h-4 w-4" />
|
|
{form.error}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if form?.success}
|
|
<div class="rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-600">
|
|
<div class="flex items-center gap-2">
|
|
<CheckCircle class="h-4 w-4" />
|
|
{form.success}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Cron Jobs -->
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
{#each cronJobs as job}
|
|
{@const lastRun = getLastRun(job.id)}
|
|
{@const lastStatus = lastRun ? getStatusBadge(lastRun.status) : null}
|
|
<div class="glass-card p-6">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<div class="rounded-lg bg-monaco-100 p-2">
|
|
<Timer class="h-5 w-5 text-monaco-600" />
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold text-slate-900">{job.name}</h3>
|
|
<p class="text-sm text-slate-500">{job.description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 space-y-2 text-sm">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-slate-500">Schedule:</span>
|
|
<span class="font-medium text-slate-700">{job.schedule}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-slate-500">Last Run:</span>
|
|
{#if lastRun}
|
|
<span class="font-medium text-slate-700">{formatDate(lastRun.started_at)}</span>
|
|
{:else}
|
|
<span class="text-slate-400">Never</span>
|
|
{/if}
|
|
</div>
|
|
{#if lastStatus}
|
|
{@const StatusIcon = lastStatus.icon}
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-slate-500">Status:</span>
|
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {lastStatus.bg} {lastStatus.color}">
|
|
<StatusIcon class="h-3 w-3" />
|
|
{lastStatus.label}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
{#if lastRun?.duration_ms}
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-slate-500">Duration:</span>
|
|
<span class="font-medium text-slate-700">{formatDuration(lastRun.duration_ms)}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-4 border-t border-slate-100 pt-4">
|
|
<form
|
|
method="POST"
|
|
action={job.action}
|
|
use:enhance={() => {
|
|
runningJob = job.id;
|
|
return async ({ update }) => {
|
|
await invalidateAll();
|
|
await update();
|
|
runningJob = null;
|
|
};
|
|
}}
|
|
>
|
|
<button
|
|
type="submit"
|
|
disabled={runningJob !== null}
|
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
|
>
|
|
{#if runningJob === job.id}
|
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
|
Running...
|
|
{:else}
|
|
<Play class="h-4 w-4" />
|
|
Run Now
|
|
{/if}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Execution History -->
|
|
<div class="glass-card p-6">
|
|
<h2 class="mb-4 text-lg font-semibold text-slate-900">Execution History</h2>
|
|
|
|
{#if cronLogs.length === 0}
|
|
<div class="py-12 text-center">
|
|
<Timer class="mx-auto h-12 w-12 text-slate-300" />
|
|
<p class="mt-4 text-slate-500">No cron executions recorded yet.</p>
|
|
<p class="mt-1 text-sm text-slate-400">Run a job above or wait for scheduled execution.</p>
|
|
</div>
|
|
{:else}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-slate-200 text-left">
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Job</th>
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Status</th>
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Started</th>
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Duration</th>
|
|
<th class="pb-3 pr-4 font-medium text-slate-600">Triggered By</th>
|
|
<th class="pb-3 font-medium text-slate-600">Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
{#each cronLogs as log}
|
|
{@const statusBadge = getStatusBadge(log.status)}
|
|
{@const BadgeIcon = statusBadge.icon}
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="py-3 pr-4 font-medium text-slate-900 whitespace-nowrap">
|
|
{log.job_name}
|
|
</td>
|
|
<td class="py-3 pr-4">
|
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {statusBadge.bg} {statusBadge.color}">
|
|
<BadgeIcon class="h-3 w-3" />
|
|
{statusBadge.label}
|
|
</span>
|
|
</td>
|
|
<td class="py-3 pr-4 text-slate-500 whitespace-nowrap">
|
|
{formatDate(log.started_at)}
|
|
</td>
|
|
<td class="py-3 pr-4 text-slate-500">
|
|
{formatDuration(log.duration_ms)}
|
|
</td>
|
|
<td class="py-3 pr-4">
|
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {log.triggered_by === 'admin-manual' ? 'bg-purple-50 text-purple-700' : 'bg-slate-100 text-slate-600'}">
|
|
{log.triggered_by === 'admin-manual' ? 'Manual' : 'Scheduled'}
|
|
</span>
|
|
</td>
|
|
<td class="py-3 text-slate-500">
|
|
{#if log.error_message}
|
|
<span class="text-red-600 text-xs">{log.error_message}</span>
|
|
{:else if log.result?.summary}
|
|
<span class="text-xs">
|
|
Sent: {log.result.summary.totalRemindersSent || 0},
|
|
Errors: {log.result.summary.totalErrors || 0}
|
|
</span>
|
|
{:else if log.result?.sent !== undefined}
|
|
<span class="text-xs">Sent: {log.result.sent}</span>
|
|
{:else}
|
|
<span class="text-slate-400">-</span>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|