Update various configuration files, components, and assets; enhance notification system and API endpoints; improve documentation and styles across the application.
This commit is contained in:
563
pages/notification/queue/retry.vue
Normal file
563
pages/notification/queue/retry.vue
Normal file
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-refresh"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Failed Jobs</h1>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshJobs"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">Handle and retry failed notification jobs.</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<rs-alert v-if="error" variant="danger" class="mb-6">
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
|
||||
<!-- Basic Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
|
||||
<p class="text-sm text-gray-600">Failed Jobs</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-yellow-600">
|
||||
{{ stats.retrying }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Retrying</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-green-600">
|
||||
{{ stats.recovered }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Recovered</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-gray-600">
|
||||
{{ stats.deadLetter }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Dead Letter</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- 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="confirmRetryAll"
|
||||
:loading="isRetryingAll"
|
||||
:disabled="isRetryingAll || failedJobs.length === 0"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Retry All
|
||||
</rs-button>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshJobs"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-sync"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoading" class="flex justify-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
||||
<span class="ml-2">Loading...</span>
|
||||
</div>
|
||||
<div v-else-if="failedJobs.length === 0" class="text-center py-8 text-gray-500">
|
||||
<Icon name="ic:outline-check-circle" class="text-3xl text-gray-400 mb-2" />
|
||||
<p>No failed jobs found</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(job, index) in failedJobs"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full mr-3 bg-red-500"></div>
|
||||
<div>
|
||||
<h4 class="font-semibold">{{ job.type }} - {{ truncateId(job.id) }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ job.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="confirmRetryJob(job)"
|
||||
:loading="retryingJobs[job.id]"
|
||||
:disabled="retryingJobs[job.id]"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Retry
|
||||
</rs-button>
|
||||
<rs-button variant="outline" size="sm" @click="viewError(job)">
|
||||
<Icon class="mr-1" name="ic:outline-visibility"></Icon>
|
||||
View
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span class="font-medium">Attempts:</span> {{ job.attempts }}/{{
|
||||
job.maxAttempts
|
||||
}}
|
||||
</div>
|
||||
<div><span class="font-medium">Error Type:</span> {{ job.errorType }}</div>
|
||||
<div>
|
||||
<span class="font-medium">Failed At:</span>
|
||||
{{ formatDateTime(job.failedAt) }}
|
||||
</div>
|
||||
<div><span class="font-medium">Next Retry:</span> {{ job.nextRetry }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="failedJobs.length > 0" class="mt-4 flex justify-center">
|
||||
<button
|
||||
class="px-3 py-1 border border-gray-300 rounded-l-md disabled:opacity-50"
|
||||
:disabled="pagination.page === 1"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div class="px-3 py-1 border-t border-b border-gray-300">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 border border-gray-300 rounded-r-md disabled:opacity-50"
|
||||
:disabled="pagination.page === pagination.totalPages || !pagination.hasMore"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Details Modal -->
|
||||
<rs-modal v-model="showErrorModal" title="Job Error Details">
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Error Message</label
|
||||
>
|
||||
<div class="bg-red-50 border border-red-200 rounded p-3 max-h-48 overflow-y-auto">
|
||||
<pre class="text-sm text-red-800 whitespace-pre-wrap">{{ selectedJob.errorMessage }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedJob.attempts && selectedJob.maxAttempts">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Retry Attempts</label>
|
||||
<div class="bg-gray-50 p-3 rounded">
|
||||
<div class="relative h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-2 rounded-full"
|
||||
:class="selectedJob.attempts >= selectedJob.maxAttempts ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
:style="{width: `${(selectedJob.attempts / selectedJob.maxAttempts) * 100}%`}"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-sm mt-1 text-gray-600">
|
||||
{{ selectedJob.attempts }} of {{ selectedJob.maxAttempts }} attempts used
|
||||
({{ selectedJob.attempts >= selectedJob.maxAttempts ? 'Max attempts reached' : `${selectedJob.maxAttempts - selectedJob.attempts} remaining` }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showErrorModal = false">Close</rs-button>
|
||||
<rs-button
|
||||
@click="confirmRetrySelectedJob"
|
||||
:loading="retryingJobs[selectedJob?.id]"
|
||||
:disabled="retryingJobs[selectedJob?.id]"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Retry Job
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Failed Jobs",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
path: "/notification/queue",
|
||||
},
|
||||
{
|
||||
name: "Failed Jobs",
|
||||
path: "/notification/queue/retry",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Basic stats
|
||||
const stats = ref({
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
recovered: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
|
||||
// Modal state
|
||||
const showErrorModal = ref(false);
|
||||
const selectedJob = ref(null);
|
||||
|
||||
// Loading states
|
||||
const isLoading = ref(false);
|
||||
const isRetrying = ref(false);
|
||||
const isRetryingAll = ref(false);
|
||||
const retryingJobs = ref({});
|
||||
|
||||
// Error state
|
||||
const error = ref(null);
|
||||
|
||||
// Failed jobs with pagination
|
||||
const failedJobs = ref([]);
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Fetch stats from API
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await useFetch("/api/notifications/queue/retry/stats");
|
||||
|
||||
if (!response.data.value) {
|
||||
throw new Error("Failed to fetch statistics");
|
||||
}
|
||||
|
||||
if (response.data.value?.success) {
|
||||
stats.value = response.data.value.data;
|
||||
} else {
|
||||
throw new Error(response.data.value.statusMessage || "Failed to fetch statistics");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching stats:", err);
|
||||
error.value = "Failed to load statistics";
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch jobs from API
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await useFetch("/api/notifications/queue/retry/jobs", {
|
||||
query: {
|
||||
page: pagination.value.page,
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data.value) {
|
||||
throw new Error("Failed to fetch jobs");
|
||||
}
|
||||
|
||||
if (response.data.value?.success) {
|
||||
failedJobs.value = response.data.value.data.jobs;
|
||||
pagination.value = response.data.value.data.pagination;
|
||||
} else {
|
||||
throw new Error(response.data.value.statusMessage || "Failed to fetch jobs");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching jobs:", err);
|
||||
error.value = "Failed to load failed jobs";
|
||||
failedJobs.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Change pagination page
|
||||
const changePage = (page) => {
|
||||
pagination.value.page = page;
|
||||
fetchJobs();
|
||||
};
|
||||
|
||||
// Refresh jobs and stats
|
||||
const refreshJobs = async () => {
|
||||
await Promise.all([fetchStats(), fetchJobs()]);
|
||||
};
|
||||
|
||||
// Format date/time
|
||||
const formatDateTime = (timestamp) => {
|
||||
if (!timestamp) return "N/A";
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return "Invalid Date";
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins} minutes ago`;
|
||||
} else if (diffMins < 1440) {
|
||||
return `${Math.floor(diffMins / 60)} hours ago`;
|
||||
} else {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error formatting date:", err);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate long IDs
|
||||
const truncateId = (id) => {
|
||||
if (!id) return "Unknown";
|
||||
if (id.length > 8) {
|
||||
return id.substring(0, 8) + '...';
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
// Show confirmation dialog for retry all
|
||||
const confirmRetryAll = async () => {
|
||||
const { $swal } = useNuxtApp();
|
||||
const result = await $swal.fire({
|
||||
title: "Retry All Jobs",
|
||||
text: `Are you sure you want to retry all ${stats.value.failed} failed jobs?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, retry all",
|
||||
cancelButtonText: "Cancel",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
retryAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Show confirmation dialog for retry single job
|
||||
const confirmRetryJob = async (job) => {
|
||||
const { $swal } = useNuxtApp();
|
||||
const result = await $swal.fire({
|
||||
title: "Retry Job",
|
||||
text: `Are you sure you want to retry this job?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, retry",
|
||||
cancelButtonText: "Cancel",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
retryJob(job);
|
||||
}
|
||||
};
|
||||
|
||||
// Retry all failed jobs
|
||||
const retryAll = async () => {
|
||||
try {
|
||||
isRetryingAll.value = true;
|
||||
error.value = null;
|
||||
|
||||
const { data } = await useFetch("/api/notifications/queue/retry/all", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!data.value?.success) {
|
||||
throw new Error(data.value?.statusMessage || "Failed to retry all jobs");
|
||||
}
|
||||
|
||||
await refreshJobs();
|
||||
|
||||
// Show success message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Success",
|
||||
text: data.value.data.message || `${data.value.data.count} jobs queued for retry`,
|
||||
icon: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error retrying all jobs:", err);
|
||||
error.value = err.message || "Failed to retry all jobs";
|
||||
|
||||
// Show error message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: err.message || "Failed to retry all jobs. Please try again.",
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
isRetryingAll.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Retry a specific job
|
||||
const retryJob = async (job) => {
|
||||
try {
|
||||
// Set loading state for this specific job
|
||||
retryingJobs.value = {
|
||||
...retryingJobs.value,
|
||||
[job.id]: true
|
||||
};
|
||||
|
||||
error.value = null;
|
||||
|
||||
const { data } = await useFetch(`/api/notifications/queue/retry/${job.id}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!data.value?.success) {
|
||||
throw new Error(data.value?.statusMessage || "Failed to retry job");
|
||||
}
|
||||
|
||||
await refreshJobs();
|
||||
|
||||
// Show success message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Success",
|
||||
text: data.value.data?.message || "Job queued for retry",
|
||||
icon: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error retrying job:", err);
|
||||
error.value = err.message || "Failed to retry job";
|
||||
|
||||
// Show error message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: err.message || "Failed to retry job. Please try again.",
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
retryingJobs.value = {
|
||||
...retryingJobs.value,
|
||||
[job.id]: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// View error details for a job
|
||||
const viewError = (job) => {
|
||||
selectedJob.value = job;
|
||||
showErrorModal.value = true;
|
||||
};
|
||||
|
||||
// Retry selected job from modal
|
||||
const confirmRetrySelectedJob = async () => {
|
||||
if (selectedJob.value) {
|
||||
await confirmRetryJob(selectedJob.value);
|
||||
}
|
||||
showErrorModal.value = false;
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
onMounted(async () => {
|
||||
await refreshJobs();
|
||||
});
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
let refreshInterval;
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(refreshJobs, 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
Reference in New Issue
Block a user