692 lines
23 KiB
Vue

<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-refresh"></Icon>
<h1 class="text-xl font-bold text-primary">Retry & Dead Letter Queue</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Handles failed jobs with retry logic and dead letter queue for permanent failures.
Critical for debugging and ensuring no message is lost without explanation.
</p>
</template>
</rs-card>
<!-- Statistics -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(stat, index) in retryStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-4 flex justify-center items-center rounded-2xl"
:class="stat.bgColor"
>
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-xl leading-tight" :class="stat.textColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Retry Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Retry Configuration</h3>
<rs-button variant="outline" size="sm" @click="showRetryConfigModal = true">
<Icon class="mr-1" name="ic:outline-settings"></Icon>
Configure
</rs-button>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div
v-for="(config, index) in retryConfigs"
:key="index"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center mb-3">
<Icon class="mr-2 text-primary" :name="config.icon"></Icon>
<h4 class="font-semibold">{{ config.type }}</h4>
</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Max Attempts:</span>
<span class="font-medium">{{ config.maxAttempts }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Backoff Strategy:</span>
<span class="font-medium">{{ config.backoffStrategy }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Initial Delay:</span>
<span class="font-medium">{{ config.initialDelay }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Max Delay:</span>
<span class="font-medium">{{ config.maxDelay }}</span>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Tabs for Failed Jobs and Dead Letter Queue -->
<rs-tab v-model="activeTab" class="mb-6">
<rs-tab-item name="failed" label="Failed Jobs">
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Failed Jobs</h3>
<div class="flex items-center gap-2">
<rs-button variant="outline" size="sm" @click="retryAllFailed">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Retry All
</rs-button>
<rs-button variant="outline" size="sm" @click="refreshFailedJobs">
<Icon class="mr-1" name="ic:outline-sync"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<!-- Filters -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
<select v-model="failedFilters.type" class="w-full p-2 border border-gray-300 rounded-md">
<option value="">All Types</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push Notification</option>
<option value="webhook">Webhook</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Error Type</label>
<select v-model="failedFilters.errorType" class="w-full p-2 border border-gray-300 rounded-md">
<option value="">All Errors</option>
<option value="network">Network Error</option>
<option value="rate_limit">Rate Limit</option>
<option value="invalid_token">Invalid Token</option>
<option value="timeout">Timeout</option>
</select>
</div>
<div class="flex items-end">
<rs-button @click="clearFailedFilters" variant="outline" class="w-full">
<Icon class="mr-1" name="ic:outline-clear"></Icon>
Clear Filters
</rs-button>
</div>
</div>
<rs-table
:field="failedJobsFields"
:data="filteredFailedJobs"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{ sortable: true, filterable: false }"
advanced
/>
</template>
</rs-card>
</rs-tab-item>
<rs-tab-item name="dead-letter" label="Dead Letter Queue">
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Dead Letter Queue</h3>
<div class="flex items-center gap-2">
<rs-button variant="outline" size="sm" @click="exportDeadLetters">
<Icon class="mr-1" name="ic:outline-download"></Icon>
Export
</rs-button>
<rs-button variant="outline" size="sm" @click="refreshDeadLetters">
<Icon class="mr-1" name="ic:outline-sync"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<!-- Filters -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Job Type</label>
<select v-model="deadLetterFilters.type" class="w-full p-2 border border-gray-300 rounded-md">
<option value="">All Types</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push Notification</option>
<option value="webhook">Webhook</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date Range</label>
<select v-model="deadLetterFilters.dateRange" class="w-full p-2 border border-gray-300 rounded-md">
<option value="">All Time</option>
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
</select>
</div>
<div class="flex items-end">
<rs-button @click="clearDeadLetterFilters" variant="outline" class="w-full">
<Icon class="mr-1" name="ic:outline-clear"></Icon>
Clear Filters
</rs-button>
</div>
</div>
<rs-table
:field="deadLetterFields"
:data="filteredDeadLetters"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{ sortable: true, filterable: false }"
advanced
/>
</template>
</rs-card>
</rs-tab-item>
</rs-tab>
<!-- Job Details Modal -->
<rs-modal v-model="showJobModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Job Details</h3>
</template>
<template #body>
<div v-if="selectedJob" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Job ID</label>
<p class="text-sm text-gray-900">{{ selectedJob.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Type</label>
<p class="text-sm text-gray-900">{{ selectedJob.type }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Attempts</label>
<p class="text-sm text-gray-900">{{ selectedJob.attempts }}/{{ selectedJob.maxAttempts }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Last Attempt</label>
<p class="text-sm text-gray-900">{{ selectedJob.lastAttempt }}</p>
</div>
</div>
<!-- Error History -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Error History</label>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="(error, index) in selectedJob.errorHistory"
:key="index"
class="bg-red-50 border border-red-200 rounded p-3"
>
<div class="flex justify-between items-start mb-1">
<span class="text-sm font-medium text-red-800">Attempt {{ error.attempt }}</span>
<span class="text-xs text-red-600">{{ error.timestamp }}</span>
</div>
<p class="text-sm text-red-700">{{ error.message }}</p>
</div>
</div>
</div>
<!-- Payload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Payload</label>
<pre class="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-40">{{ JSON.stringify(selectedJob.payload, null, 2) }}</pre>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showJobModal = false">Close</rs-button>
<rs-button v-if="selectedJob?.canRetry" @click="retryJob(selectedJob)" variant="primary">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Retry Job
</rs-button>
<rs-button v-if="!selectedJob?.isDead" @click="moveToDeadLetter(selectedJob)" variant="danger">
<Icon class="mr-1" name="ic:outline-delete"></Icon>
Move to DLQ
</rs-button>
</div>
</template>
</rs-modal>
<!-- Retry Configuration Modal -->
<rs-modal v-model="showRetryConfigModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Retry Configuration</h3>
</template>
<template #body>
<div class="space-y-6">
<div
v-for="(config, index) in retryConfigs"
:key="index"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center mb-4">
<Icon class="mr-2 text-primary" :name="config.icon"></Icon>
<h4 class="font-semibold">{{ config.type }} Configuration</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Attempts</label>
<input
v-model.number="config.maxAttempts"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
max="10"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Backoff Strategy</label>
<select v-model="config.backoffStrategy" class="w-full p-2 border border-gray-300 rounded-md">
<option value="fixed">Fixed</option>
<option value="exponential">Exponential</option>
<option value="linear">Linear</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Initial Delay (seconds)</label>
<input
v-model.number="config.initialDelay"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Delay (seconds)</label>
<input
v-model.number="config.maxDelay"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showRetryConfigModal = false">Cancel</rs-button>
<rs-button @click="saveRetryConfig" variant="primary">Save Configuration</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Retry & Dead Letter Queue",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Retry & Dead Letter",
path: "/notification/queue-scheduler/retry",
},
],
});
// Reactive data
const activeTab = ref('failed');
const showJobModal = ref(false);
const showRetryConfigModal = ref(false);
const selectedJob = ref(null);
// Filters
const failedFilters = ref({
type: '',
errorType: ''
});
const deadLetterFilters = ref({
type: '',
dateRange: ''
});
// Statistics
const retryStats = ref([
{
title: "Failed Jobs",
value: "156",
icon: "ic:outline-error",
bgColor: "bg-red-100",
iconColor: "text-red-600",
textColor: "text-red-600"
},
{
title: "Retrying",
value: "23",
icon: "ic:outline-refresh",
bgColor: "bg-yellow-100",
iconColor: "text-yellow-600",
textColor: "text-yellow-600"
},
{
title: "Dead Letters",
value: "45",
icon: "ic:outline-delete",
bgColor: "bg-gray-100",
iconColor: "text-gray-600",
textColor: "text-gray-600"
},
{
title: "Success Rate",
value: "94.2%",
icon: "ic:outline-trending-up",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600"
}
]);
// Retry configurations
const retryConfigs = ref([
{
type: 'Email',
icon: 'ic:outline-email',
maxAttempts: 3,
backoffStrategy: 'exponential',
initialDelay: 30,
maxDelay: 300
},
{
type: 'SMS',
icon: 'ic:outline-sms',
maxAttempts: 5,
backoffStrategy: 'linear',
initialDelay: 10,
maxDelay: 120
},
{
type: 'Push',
icon: 'ic:outline-notifications',
maxAttempts: 2,
backoffStrategy: 'fixed',
initialDelay: 60,
maxDelay: 60
},
{
type: 'Webhook',
icon: 'ic:outline-webhook',
maxAttempts: 4,
backoffStrategy: 'exponential',
initialDelay: 15,
maxDelay: 600
}
]);
// Table fields for failed jobs
const failedJobsFields = ref([
{ key: 'id', label: 'Job ID', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{ key: 'attempts', label: 'Attempts', sortable: true },
{ key: 'lastError', label: 'Last Error', sortable: false },
{ key: 'lastAttempt', label: 'Last Attempt', sortable: true },
{ key: 'nextRetry', label: 'Next Retry', sortable: true },
{ key: 'actions', label: 'Actions', sortable: false }
]);
// Table fields for dead letter queue
const deadLetterFields = ref([
{ key: 'id', label: 'Job ID', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{ key: 'totalAttempts', label: 'Total Attempts', sortable: true },
{ key: 'finalError', label: 'Final Error', sortable: false },
{ key: 'failedAt', label: 'Failed At', sortable: true },
{ key: 'actions', label: 'Actions', sortable: false }
]);
// Mock failed jobs data
const failedJobs = ref([
{
id: 'job_fail_001',
type: 'email',
attempts: 2,
maxAttempts: 3,
lastError: 'SMTP connection timeout',
lastAttempt: '2024-01-15 10:30:00',
nextRetry: '2024-01-15 11:00:00',
canRetry: true,
isDead: false,
payload: { to: 'user@example.com', subject: 'Welcome!' },
errorHistory: [
{ attempt: 1, message: 'SMTP server unavailable', timestamp: '2024-01-15 10:00:00' },
{ attempt: 2, message: 'SMTP connection timeout', timestamp: '2024-01-15 10:30:00' }
]
},
{
id: 'job_fail_002',
type: 'sms',
attempts: 3,
maxAttempts: 5,
lastError: 'Invalid phone number format',
lastAttempt: '2024-01-15 10:25:00',
nextRetry: '2024-01-15 10:45:00',
canRetry: true,
isDead: false,
payload: { to: '+invalid', message: 'Your OTP is 123456' },
errorHistory: [
{ attempt: 1, message: 'Invalid phone number format', timestamp: '2024-01-15 10:00:00' },
{ attempt: 2, message: 'Invalid phone number format', timestamp: '2024-01-15 10:15:00' },
{ attempt: 3, message: 'Invalid phone number format', timestamp: '2024-01-15 10:25:00' }
]
}
]);
// Mock dead letter queue data
const deadLetters = ref([
{
id: 'job_dead_001',
type: 'push',
totalAttempts: 2,
finalError: 'Invalid device token - token expired',
failedAt: '2024-01-14 15:30:00',
canRetry: false,
isDead: true,
payload: { token: 'expired_token_123', title: 'New Message' },
errorHistory: [
{ attempt: 1, message: 'Invalid device token', timestamp: '2024-01-14 15:00:00' },
{ attempt: 2, message: 'Invalid device token - token expired', timestamp: '2024-01-14 15:30:00' }
]
},
{
id: 'job_dead_002',
type: 'webhook',
totalAttempts: 4,
finalError: 'Endpoint not found (404)',
failedAt: '2024-01-13 12:45:00',
canRetry: false,
isDead: true,
payload: { url: 'https://api.example.com/webhook', data: { event: 'order.completed' } },
errorHistory: [
{ attempt: 1, message: 'Connection timeout', timestamp: '2024-01-13 12:00:00' },
{ attempt: 2, message: 'Connection timeout', timestamp: '2024-01-13 12:15:00' },
{ attempt: 3, message: 'Endpoint not found (404)', timestamp: '2024-01-13 12:30:00' },
{ attempt: 4, message: 'Endpoint not found (404)', timestamp: '2024-01-13 12:45:00' }
]
}
]);
// Computed filtered data
const filteredFailedJobs = computed(() => {
let filtered = failedJobs.value;
if (failedFilters.value.type) {
filtered = filtered.filter(job => job.type === failedFilters.value.type);
}
// Add actions column
return filtered.map(job => ({
...job,
attempts: `${job.attempts}/${job.maxAttempts}`,
actions: h('div', { class: 'flex gap-2' }, [
h('button', {
class: 'text-blue-600 hover:text-blue-800 text-sm',
onClick: () => viewJobDetails(job)
}, 'View'),
h('button', {
class: 'text-green-600 hover:text-green-800 text-sm',
onClick: () => retryJob(job)
}, 'Retry'),
h('button', {
class: 'text-red-600 hover:text-red-800 text-sm',
onClick: () => moveToDeadLetter(job)
}, 'Move to DLQ')
])
}));
});
const filteredDeadLetters = computed(() => {
let filtered = deadLetters.value;
if (deadLetterFilters.value.type) {
filtered = filtered.filter(job => job.type === deadLetterFilters.value.type);
}
// Add actions column
return filtered.map(job => ({
...job,
actions: h('div', { class: 'flex gap-2' }, [
h('button', {
class: 'text-blue-600 hover:text-blue-800 text-sm',
onClick: () => viewJobDetails(job)
}, 'View'),
h('button', {
class: 'text-red-600 hover:text-red-800 text-sm',
onClick: () => deleteDeadLetter(job)
}, 'Delete')
])
}));
});
// Methods
function viewJobDetails(job) {
selectedJob.value = job;
showJobModal.value = true;
}
function retryJob(job) {
// Mock retry logic
job.attempts += 1;
job.lastAttempt = new Date().toLocaleString();
job.nextRetry = new Date(Date.now() + 30 * 60000).toLocaleString(); // 30 minutes from now
if (job.attempts >= job.maxAttempts) {
moveToDeadLetter(job);
}
showJobModal.value = false;
}
function retryAllFailed() {
failedJobs.value.forEach(job => {
if (job.canRetry) {
retryJob(job);
}
});
}
function moveToDeadLetter(job) {
// Move job to dead letter queue
const deadJob = {
...job,
totalAttempts: job.attempts,
finalError: job.lastError,
failedAt: new Date().toLocaleString(),
canRetry: false,
isDead: true
};
deadLetters.value.unshift(deadJob);
// Remove from failed jobs
const index = failedJobs.value.findIndex(j => j.id === job.id);
if (index > -1) {
failedJobs.value.splice(index, 1);
}
showJobModal.value = false;
}
function deleteDeadLetter(job) {
const index = deadLetters.value.findIndex(j => j.id === job.id);
if (index > -1) {
deadLetters.value.splice(index, 1);
}
}
function clearFailedFilters() {
failedFilters.value = {
type: '',
errorType: ''
};
}
function clearDeadLetterFilters() {
deadLetterFilters.value = {
type: '',
dateRange: ''
};
}
function refreshFailedJobs() {
// Mock refresh
console.log('Refreshing failed jobs...');
}
function refreshDeadLetters() {
// Mock refresh
console.log('Refreshing dead letters...');
}
function exportDeadLetters() {
// Mock export
console.log('Exporting dead letters...');
}
function saveRetryConfig() {
// Mock save
console.log('Saving retry configuration...');
showRetryConfigModal.value = false;
}
</script>
<style lang="scss" scoped></style>