382 lines
10 KiB
Vue
382 lines
10 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">Failed Jobs</h1>
|
|
</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">
|
|
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
|
|
<p class="text-sm text-gray-600">Failed Jobs</p>
|
|
</div>
|
|
</rs-card>
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-yellow-600">
|
|
{{ stats.retrying }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600">Retrying</p>
|
|
</div>
|
|
</rs-card>
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-green-600">
|
|
{{ stats.recovered }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600">Recovered</p>
|
|
</div>
|
|
</rs-card>
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-gray-600">
|
|
{{ stats.deadLetter }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600">Dead Letter</p>
|
|
</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="retryAll"
|
|
: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">
|
|
<rs-spinner size="lg" />
|
|
</div>
|
|
<div v-else-if="failedJobs.length === 0" class="text-center py-8 text-gray-500">
|
|
No failed jobs found
|
|
</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 }} - {{ 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="retryJob(job)"
|
|
:loading="isRetrying"
|
|
:disabled="isRetrying"
|
|
>
|
|
<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-2 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">Last Error:</span> {{ job.errorType }}</div>
|
|
<div><span class="font-medium">Failed At:</span> {{ 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">
|
|
<rs-pagination
|
|
v-model="pagination.page"
|
|
:total-pages="pagination.totalPages"
|
|
:total-items="pagination.totalItems"
|
|
@update:model-value="fetchJobs"
|
|
/>
|
|
</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">
|
|
<p class="text-sm text-red-800">{{ selectedJob.errorMessage }}</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="retrySelectedJob"
|
|
:loading="isRetrying"
|
|
:disabled="isRetrying"
|
|
>
|
|
<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);
|
|
|
|
// 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 {
|
|
const { data } = await useFetch("/api/notifications/queue/retry/stats");
|
|
if (data.value?.success) {
|
|
stats.value = data.value.data;
|
|
}
|
|
} 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 { data } = await useFetch("/api/notifications/queue/retry/jobs", {
|
|
query: {
|
|
page: pagination.value.page,
|
|
limit: 10,
|
|
},
|
|
});
|
|
|
|
if (data.value?.success) {
|
|
failedJobs.value = data.value.data.jobs;
|
|
pagination.value = data.value.data.pagination;
|
|
}
|
|
} catch (err) {
|
|
console.error("Error fetching jobs:", err);
|
|
error.value = "Failed to load failed jobs";
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const refreshJobs = async () => {
|
|
await Promise.all([fetchStats(), fetchJobs()]);
|
|
};
|
|
|
|
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) {
|
|
await refreshJobs();
|
|
// Show success message
|
|
const { $swal } = useNuxtApp();
|
|
await $swal.fire({
|
|
title: "Success",
|
|
text: data.value.data.message,
|
|
icon: "success",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("Error retrying all jobs:", err);
|
|
error.value = "Failed to retry all jobs";
|
|
// Show error message
|
|
const { $swal } = useNuxtApp();
|
|
await $swal.fire({
|
|
title: "Error",
|
|
text: "Failed to retry all jobs. Please try again.",
|
|
icon: "error",
|
|
});
|
|
} finally {
|
|
isRetryingAll.value = false;
|
|
}
|
|
};
|
|
|
|
const retryJob = async (job) => {
|
|
try {
|
|
isRetrying.value = true;
|
|
error.value = null;
|
|
|
|
const { data } = await useFetch(`/api/notifications/queue/retry/${job.id}`, {
|
|
method: "POST",
|
|
});
|
|
|
|
if (data.value?.success) {
|
|
await refreshJobs();
|
|
// Show success message
|
|
const { $swal } = useNuxtApp();
|
|
await $swal.fire({
|
|
title: "Success",
|
|
text: "Job queued for retry",
|
|
icon: "success",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("Error retrying job:", err);
|
|
error.value = "Failed to retry job";
|
|
// Show error message
|
|
const { $swal } = useNuxtApp();
|
|
await $swal.fire({
|
|
title: "Error",
|
|
text: "Failed to retry job. Please try again.",
|
|
icon: "error",
|
|
});
|
|
} finally {
|
|
isRetrying.value = false;
|
|
}
|
|
};
|
|
|
|
const viewError = (job) => {
|
|
selectedJob.value = job;
|
|
showErrorModal.value = true;
|
|
};
|
|
|
|
const retrySelectedJob = async () => {
|
|
if (selectedJob.value) {
|
|
await retryJob(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>
|