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:
Haqeem Solehan
2025-10-16 16:05:39 +08:00
commit b124ff8092
336 changed files with 94392 additions and 0 deletions

View File

@@ -0,0 +1,537 @@
<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-batch-prediction"></Icon>
<h1 class="text-xl font-bold text-primary">Batch Processing</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Process notifications in batches for better efficiency.
</p>
</template>
</rs-card>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<rs-card
class="cursor-pointer hover:shadow-md transition-shadow"
@click="showCreateModal = true"
>
<div class="p-4 flex items-center gap-4">
<div class="p-3 bg-blue-100 rounded-lg">
<Icon class="text-blue-600 text-xl" name="ic:outline-add"></Icon>
</div>
<div>
<h3 class="font-semibold text-blue-600">Create New Batch</h3>
<p class="text-sm text-gray-600">Start a new batch processing job</p>
</div>
</div>
</rs-card>
<rs-card class="cursor-pointer hover:shadow-md transition-shadow">
<div class="p-4 flex items-center gap-4">
<div class="p-3 bg-green-100 rounded-lg">
<Icon class="text-green-600 text-xl" name="ic:outline-schedule"></Icon>
</div>
<div>
<h3 class="font-semibold text-green-600">Schedule Batch</h3>
<p class="text-sm text-gray-600">Schedule for later execution</p>
</div>
</div>
</rs-card>
</div>
<!-- Batch 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-blue-600">{{ stats.pending }}</h3>
<p class="text-sm text-gray-600">Pending</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-yellow-600">
{{ stats.processing }}
</h3>
<p class="text-sm text-gray-600">Processing</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-green-600">
{{ stats.completed }}
</h3>
<p class="text-sm text-gray-600">Completed</p>
</div>
</rs-card>
<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</p>
</div>
</rs-card>
</div>
<!-- Active Batches -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Active Batches</h3>
<rs-button
variant="outline"
size="sm"
@click="refreshBatches"
:loading="isLoading"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<rs-spinner size="lg" />
</div>
<!-- Empty State -->
<div v-else-if="batches.length === 0" class="text-center py-8">
<Icon name="ic:outline-inbox" class="text-4xl text-gray-400 mb-2" />
<p class="text-gray-600">No batches found</p>
</div>
<!-- Batches List -->
<div v-else class="space-y-4">
<div
v-for="batch in batches"
:key="batch.id"
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"
:class="getStatusColor(batch.status)"
></div>
<div>
<h4 class="font-semibold">{{ batch.name }}</h4>
<p class="text-sm text-gray-600">{{ batch.description }}</p>
</div>
</div>
<div class="text-right">
<span class="text-sm font-medium capitalize">{{ batch.status }}</span>
<p class="text-xs text-gray-500">
{{ formatDate(batch.time) }}
</p>
</div>
</div>
<!-- Progress Info -->
<div class="space-y-2">
<div class="text-sm text-gray-600">
Progress: {{ batch.processed }}/{{ batch.total }} ({{
Math.round((batch.processed / batch.total) * 100)
}}%)
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full"
:style="{
width: `${Math.round((batch.processed / batch.total) * 100)}%`,
}"
></div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if="pagination.totalPages > 1"
class="flex justify-center items-center space-x-2 mt-4"
>
<rs-button
variant="outline"
size="sm"
:disabled="pagination.page === 1"
@click="pagination.page--"
>
Previous
</rs-button>
<span class="text-sm text-gray-600">
Page {{ pagination.page }} of {{ pagination.totalPages }}
</span>
<rs-button
variant="outline"
size="sm"
:disabled="pagination.page === pagination.totalPages"
@click="pagination.page++"
>
Next
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- Create Batch Modal -->
<rs-modal v-model="showCreateModal" title="Create New Batch">
<div class="space-y-4">
<!-- Basic Information -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Name</label>
<input
v-model="newBatch.name"
type="text"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="Enter batch name"
/>
</div>
<!-- Message Type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
<select
v-model="newBatch.type"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">Select type</option>
<option value="email">Email</option>
<option value="push">Push Notification</option>
</select>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
<select
v-model="newBatch.priority"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<!-- Template Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Template (Optional)</label
>
<select
v-model="newBatch.template"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">No template</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.name }}
</option>
</select>
</div>
<!-- User Segment -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>User Segment (Optional)</label
>
<select
v-model="newBatch.segment"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">All Users</option>
<option v-for="segment in segments" :key="segment.id" :value="segment.value">
{{ segment.name }}
</option>
</select>
</div>
<!-- Scheduled Time -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Schedule For (Optional)</label
>
<input
v-model="newBatch.scheduledAt"
type="datetime-local"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
<textarea
v-model="newBatch.description"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Describe this batch"
></textarea>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button
variant="outline"
@click="showCreateModal = false"
:disabled="isCreating"
>Cancel</rs-button
>
<rs-button @click="createBatch" :loading="isCreating" :disabled="isCreating">
{{ isCreating ? "Creating..." : "Create Batch" }}
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
import { onMounted, watch } from "vue";
import { useToast } from "vue-toastification";
definePageMeta({
title: "Batch Processing",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Batch Processing",
path: "/notification/queue-scheduler/batch",
},
],
});
// Basic stats
const stats = ref({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
});
// Pagination
const pagination = ref({
page: 1,
limit: 10,
total: 0,
totalPages: 0,
});
// Batches list
const batches = ref([]);
// Loading states
const isLoading = ref(false);
const isCreating = ref(false);
// Modal state
const showCreateModal = ref(false);
// Form data
const newBatch = ref({
name: "",
type: "",
description: "",
scheduledAt: "",
template: "",
segment: "",
priority: "medium",
});
// Templates and segments (will be fetched)
const templates = ref([]);
const segments = ref([]);
// Fetch data on mount
onMounted(async () => {
await Promise.all([fetchStats(), fetchBatches(), fetchTemplates(), fetchSegments()]);
});
// Watch for page changes
watch(() => pagination.value.page, fetchBatches);
// Fetch functions
async function fetchStats() {
try {
const response = await $fetch("/api/notifications/batch/stats");
if (response.success) {
stats.value = response.data;
} else {
throw new Error(response.statusMessage || "Failed to fetch statistics");
}
} catch (error) {
console.error("Error fetching stats:", error);
// Show error notification
useToast().error("Failed to fetch statistics");
}
}
async function fetchBatches() {
try {
isLoading.value = true;
const response = await $fetch("/api/notifications/batch", {
params: {
page: pagination.value.page,
limit: pagination.value.limit,
},
});
if (response.success) {
batches.value = response.data.batches;
pagination.value = {
...pagination.value,
total: response.data.pagination.total,
totalPages: response.data.pagination.totalPages,
};
} else {
throw new Error(response.statusMessage || "Failed to fetch batches");
}
} catch (error) {
console.error("Error fetching batches:", error);
useToast().error("Failed to fetch batches");
batches.value = [];
} finally {
isLoading.value = false;
}
}
async function fetchTemplates() {
try {
const response = await $fetch("/api/notifications/templates");
if (Array.isArray(response)) {
templates.value = response;
} else if (response && response.success && Array.isArray(response.data)) {
templates.value = response.data;
} else {
templates.value = [];
}
} catch (error) {
console.error("Error fetching templates:", error);
templates.value = [];
}
}
async function fetchSegments() {
try {
const response = await $fetch("/api/notifications/segments");
if (Array.isArray(response)) {
segments.value = response;
} else if (response && response.success && Array.isArray(response.data)) {
segments.value = response.data;
} else {
segments.value = [];
}
} catch (error) {
console.error("Error fetching segments:", error);
segments.value = [];
}
}
// Create batch
async function createBatch() {
try {
isCreating.value = true;
// Validate form
if (!newBatch.value.name || !newBatch.value.type) {
useToast().error("Please fill in all required fields");
return;
}
// Create a copy of the batch data for submission
const batchData = { ...newBatch.value };
// Format scheduledAt if it exists
if (batchData.scheduledAt) {
try {
const date = new Date(batchData.scheduledAt);
if (isNaN(date.getTime())) {
useToast().error("Invalid scheduled date");
return;
}
batchData.scheduledAt = date.toISOString();
} catch (error) {
useToast().error("Invalid scheduled date");
return;
}
}
const response = await $fetch("/api/notifications/batch", {
method: "POST",
body: batchData,
});
if (!response || !response.success) {
throw new Error(response?.statusMessage || "Failed to create batch");
}
// Show success message
useToast().success("Batch created successfully");
// Reset form
newBatch.value = {
name: "",
type: "",
description: "",
scheduledAt: "",
template: "",
segment: "",
priority: "medium",
};
// Close modal
showCreateModal.value = false;
// Refresh data
await Promise.all([fetchStats(), fetchBatches()]);
} catch (error) {
console.error("Error creating batch:", error);
useToast().error(error.message || "Failed to create batch");
} finally {
isCreating.value = false;
}
}
// Utility function for status color
const getStatusColor = (status) => {
const colors = {
draft: "bg-gray-500",
scheduled: "bg-blue-500",
sending: "bg-yellow-500",
sent: "bg-green-500",
failed: "bg-red-500",
};
return colors[status] || "bg-gray-500";
};
// Format date
const formatDate = (date) => {
return new Date(date).toLocaleString();
};
// Refresh data
const refreshBatches = async () => {
await Promise.all([fetchStats(), fetchBatches()]);
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,192 @@
<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-schedule"></Icon>
<h1 class="text-xl font-bold text-primary">Queue</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">Manage notification queues and scheduled tasks.</p>
</template>
</rs-card>
<!-- Basic Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<rs-card v-for="(stat, index) in queueStats" :key="index">
<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', stat.colorClass]">
{{ stat.value }}
</h3>
<p class="text-sm text-gray-600">{{ stat.label }}</p>
</template>
</div>
</rs-card>
</div>
<!-- Main Features -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<rs-card
v-for="(feature, index) in features"
:key="index"
class="cursor-pointer hover:shadow-md transition-shadow"
@click="navigateTo(feature.path)"
>
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" :name="feature.icon"></Icon>
<h3 class="font-semibold text-primary">{{ feature.title }}</h3>
</div>
</template>
<template #body>
<p class="text-gray-600 text-sm">{{ feature.description }}</p>
</template>
<template #footer>
<rs-button variant="outline" size="sm" class="w-full"> Open </rs-button>
</template>
</rs-card>
</div>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mt-4">
{{ error }}
</rs-alert>
</div>
</template>
<script setup>
definePageMeta({
title: "Queue & Scheduler",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
],
});
const { $swal } = useNuxtApp();
// Reactive state
const isLoading = ref(true);
const error = ref(null);
const queueStats = ref([]);
// Hardcoded features
const features = ref([
{
title: "Queue Monitor",
description: "View and manage current notification queues",
icon: "ic:outline-monitor",
path: "/notification/queue/monitor",
},
{
title: "Performance",
description: "Check system performance and metrics",
icon: "ic:outline-speed",
path: "/notification/queue/performance",
},
// {
// title: "Batch Processing",
// description: "Process notifications in batches",
// icon: "ic:outline-batch-prediction",
// path: "/notification/queue/batch",
// },
{
title: "Failed Jobs",
description: "Handle and retry failed notifications",
icon: "ic:outline-refresh",
path: "/notification/queue/retry",
},
]);
// Fetch queue statistics
async function fetchQueueStats() {
try {
const { data } = await useFetch("/api/notifications/queue/stats", {
method: "GET",
});
console.log(data.value);
if (data.value?.success) {
queueStats.value = [
{
value: data.value.data.pending,
label: "Pending Jobs",
colorClass: "text-primary",
},
{
value: data.value.data.completed,
label: "Completed Today",
colorClass: "text-green-600",
},
{
value: data.value.data.failed,
label: "Failed Jobs",
colorClass: "text-red-600",
},
];
} else {
throw new Error(data.value?.message || "Failed to fetch queue statistics");
}
} catch (err) {
console.error("Error fetching queue stats:", err);
error.value = "Failed to load queue statistics. Please try again later.";
// Set default values for stats
queueStats.value = [
{ value: "-", label: "Pending Jobs", colorClass: "text-primary" },
{ value: "-", label: "Completed Today", colorClass: "text-green-600" },
{ value: "-", label: "Failed Jobs", colorClass: "text-red-600" },
];
} finally {
isLoading.value = false;
}
}
// Initialize data
onMounted(async () => {
try {
isLoading.value = true;
error.value = null;
await fetchQueueStats();
} catch (err) {
console.error("Error initializing queue page:", err);
error.value = "Failed to initialize the page. Please refresh to try again.";
} finally {
isLoading.value = false;
}
});
// Auto-refresh stats every 30 seconds
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(async () => {
await fetchQueueStats();
}, 30000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,339 @@
<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-monitor"></Icon>
<h1 class="text-xl font-bold text-primary">Queue Monitor</h1>
</div>
<rs-button
variant="outline"
size="sm"
@click="refreshData"
:loading="isLoading"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">Monitor current notification queues and job statuses.</p>
</template>
</rs-card>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mb-6">
{{ error }}
</rs-alert>
<!-- Loading State -->
<div v-if="isLoading" class="mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<rs-card v-for="i in 4" :key="i">
<div class="p-4 text-center">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2 animate-pulse"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto animate-pulse"></div>
</div>
</rs-card>
</div>
</div>
<!-- Basic Stats -->
<div v-else 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-blue-600">{{ stats.pending }}</h3>
<p class="text-sm text-gray-600">Pending</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-yellow-600">
{{ stats.processing || 0 }}
</h3>
<p class="text-sm text-gray-600">Processing</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-green-600">
{{ stats.completed }}
</h3>
<p class="text-sm text-gray-600">Completed</p>
</div>
</rs-card>
<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</p>
</div>
</rs-card>
</div>
<!-- Job List -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Recent Jobs</h3>
<div>
<select
v-model="statusFilter"
class="px-2 py-1 border border-gray-300 rounded-md text-sm mr-2"
@change="fetchJobs()"
>
<option value="">All Statuses</option>
<option value="queued">Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</template>
<template #body>
<div v-if="isLoadingJobs" class="p-8 text-center">
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"
></div>
<p class="mt-2 text-gray-600">Loading jobs...</p>
</div>
<div v-else-if="jobs.length === 0" class="p-8 text-center">
<Icon name="ic:outline-info" class="text-3xl text-gray-400 mb-2" />
<p class="text-gray-600">No jobs found</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(job, index) in jobs"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center">
<div
class="w-3 h-3 rounded-full mr-3"
:class="getStatusColor(job.status)"
></div>
<div>
<p class="font-medium">{{ job.type }} - {{ truncateId(job.id) }}</p>
<p class="text-sm text-gray-600">{{ job.description }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium capitalize">{{ job.status }}</p>
<p class="text-xs text-gray-500">{{ formatTime(job.createdAt) }}</p>
</div>
</div>
<!-- Pagination -->
<div v-if="pagination.totalPages > 1" class="flex justify-center mt-4">
<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 text-sm">
{{ 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"
@click="changePage(pagination.page + 1)"
>
Next
</button>
</div>
</div>
</template>
</rs-card>
</div>
</template>
<script setup>
definePageMeta({
title: "Queue Monitor",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
{
name: "Monitor",
path: "/notification/queue/monitor",
},
],
});
// Loading and error state
const isLoading = ref(false);
const isLoadingJobs = ref(false);
const error = ref(null);
const statusFilter = ref("");
// Stats data
const stats = ref({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
});
// Jobs data with pagination
const jobs = 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 { data } = await useFetch("/api/notifications/queue/stats");
if (!data.value) throw new Error("Failed to fetch statistics");
if (data.value?.success) {
stats.value = {
pending: data.value.data.pending || 0,
processing: data.value.data.processing || 0,
completed: data.value.data.completed || 0,
failed: data.value.data.failed || 0,
};
} else {
throw new Error(data.value.statusMessage || "Failed to fetch statistics");
}
} catch (error) {
console.error("Error fetching stats:", error);
error.value = `Failed to load statistics: ${error.message}`;
} finally {
isLoading.value = false;
}
};
// Fetch jobs from API
const fetchJobs = async () => {
try {
isLoadingJobs.value = true;
const params = {
page: pagination.value.page,
limit: 10,
};
if (statusFilter.value) {
params.status = statusFilter.value;
}
const { data } = await useFetch("/api/notifications/queue/jobs", {
query: params,
});
if (!data.value) throw new Error("Failed to fetch jobs");
if (data.value?.success) {
jobs.value = data.value.data.jobs;
pagination.value = data.value.data.pagination;
} else {
throw new Error(data.value.statusMessage || "Failed to fetch jobs");
}
} catch (error) {
console.error("Error fetching jobs:", error);
error.value = `Failed to load jobs: ${error.message}`;
jobs.value = [];
} finally {
isLoadingJobs.value = false;
}
};
// Change page
const changePage = (page) => {
pagination.value.page = page;
fetchJobs();
};
// Status color mapping
const getStatusColor = (status) => {
const colors = {
queued: "bg-blue-500",
pending: "bg-blue-500",
processing: "bg-yellow-500",
completed: "bg-green-500",
sent: "bg-green-500",
failed: "bg-red-500",
};
return colors[status] || "bg-gray-500";
};
// Format time for display
const formatTime = (timestamp) => {
if (!timestamp) return "Unknown";
try {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (isNaN(diffMins)) return "Unknown";
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} min ago`;
if (diffMins < 1440) {
const hours = Math.floor(diffMins / 60);
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
}
return date.toLocaleString();
} catch (e) {
console.error("Error formatting time:", e);
return "Unknown";
}
};
// Truncate long IDs
const truncateId = (id) => {
if (!id) return "Unknown";
if (id.length > 8) {
return id.substring(0, 8) + "...";
}
return id;
};
// Refresh data
const refreshData = async () => {
await Promise.all([fetchStats(), fetchJobs()]);
};
// Auto-refresh every 30 seconds
let refreshInterval;
onMounted(() => {
refreshData();
refreshInterval = setInterval(refreshData, 30000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,548 @@
<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-speed"></Icon>
<h1 class="text-xl font-bold text-primary">Performance</h1>
</div>
<rs-button
variant="outline"
size="sm"
@click="refreshMetrics"
: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">Monitor system performance and queue processing metrics.</p>
</template>
</rs-card>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mb-6">
{{ error }}
</rs-alert>
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-12 text-gray-600">
<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 metrics...</span>
</div>
<div v-else>
<!-- Key Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-blue-600">
{{ metrics.throughput }}
</h3>
<p class="text-sm text-gray-600">Messages/min</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-green-600">{{ metrics.uptime }}%</h3>
<p class="text-sm text-gray-600">System Uptime</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-purple-600">
{{ metrics.workers }}
</h3>
<p class="text-sm text-gray-600">Active Workers</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-orange-600">{{ metrics.queueLoad }}%</h3>
<p class="text-sm text-gray-600">Queue Load</p>
</div>
</rs-card>
</div>
<!-- Performance Summary -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Throughput Summary -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">Throughput Summary</h3>
</template>
<template #body>
<div class="space-y-4">
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Current Rate</span>
<span class="font-bold">{{ throughput.current }}/min</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Peak Today</span>
<span class="font-bold">{{ throughput.peak }}/min</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Average</span>
<span class="font-bold">{{ throughput.average }}/min</span>
</div>
</div>
</template>
</rs-card>
<!-- System Status -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">System Status</h3>
</template>
<template #body>
<div class="space-y-4">
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Uptime Today</span>
<span class="font-bold text-green-600">
{{ systemStatus.uptimeToday }}%
</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Response Time</span>
<span class="font-bold">{{ systemStatus.responseTime }}ms</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Error Rate</span>
<span class="font-bold text-red-600">{{ systemStatus.errorRate }}%</span>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Historical Performance Chart -->
<rs-card class="mt-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Historical Performance</h3>
<div class="flex items-center space-x-2">
<select
v-model="historyFilter.metric"
class="px-2 py-1 text-sm border border-gray-300 rounded"
@change="fetchHistoricalData"
>
<option value="throughput">Throughput</option>
<option value="error_rate">Error Rate</option>
<option value="response_time">Response Time</option>
</select>
<select
v-model="historyFilter.period"
class="px-2 py-1 text-sm border border-gray-300 rounded"
@change="fetchHistoricalData"
>
<option value="hour">Last Hour</option>
<option value="day">Last 24 Hours</option>
<option value="week">Last Week</option>
<option value="month">Last Month</option>
</select>
</div>
</div>
</template>
<template #body>
<div v-if="isLoadingHistory" class="flex flex-col items-center justify-center h-64 bg-gray-50">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
<p class="mt-4 text-gray-500">Loading historical data...</p>
</div>
<div v-else-if="historyError" class="flex flex-col items-center justify-center h-64 bg-red-50">
<Icon name="ic:outline-error" class="text-6xl text-red-300" />
<p class="mt-4 text-red-500">{{ historyError }}</p>
<rs-button variant="outline" size="sm" class="mt-4" @click="fetchHistoricalData">
<Icon name="ic:outline-refresh" class="mr-1" />
Retry
</rs-button>
</div>
<div v-else-if="!historicalData.dataPoints || historicalData.dataPoints.length === 0" class="flex flex-col items-center justify-center h-64 bg-gray-50">
<Icon name="ic:outline-insert-chart" class="text-6xl text-gray-300" />
<p class="mt-4 text-gray-500">No historical data available for the selected period</p>
</div>
<div v-else>
<!-- Chart Summary -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="bg-green-50 p-2 rounded text-center">
<p class="text-sm text-green-700">Maximum</p>
<p class="text-lg font-bold text-green-600">{{ historicalData.summary.max }}{{ getMetricUnit() }}</p>
</div>
<div class="bg-blue-50 p-2 rounded text-center">
<p class="text-sm text-blue-700">Average</p>
<p class="text-lg font-bold text-blue-600">{{ historicalData.summary.avg }}{{ getMetricUnit() }}</p>
</div>
<div class="bg-purple-50 p-2 rounded text-center">
<p class="text-sm text-purple-700">Trend</p>
<p class="text-lg font-bold text-purple-600">
<Icon
:name="historicalData.summary.trend === 'increasing' ? 'ic:outline-trending-up' : 'ic:outline-trending-down'"
class="inline-block mr-1"
/>
{{ historicalData.summary.trend }}
</p>
</div>
</div>
<!-- Simple Line Chart -->
<div class="h-64 w-full relative">
<!-- Y-axis labels -->
<div class="absolute top-0 left-0 h-full flex flex-col justify-between text-xs text-gray-500">
<div>{{ Math.ceil(historicalData.summary.max * 1.1) }}{{ getMetricUnit() }}</div>
<div>{{ Math.ceil(historicalData.summary.max * 0.75) }}{{ getMetricUnit() }}</div>
<div>{{ Math.ceil(historicalData.summary.max * 0.5) }}{{ getMetricUnit() }}</div>
<div>{{ Math.ceil(historicalData.summary.max * 0.25) }}{{ getMetricUnit() }}</div>
<div>0{{ getMetricUnit() }}</div>
</div>
<!-- Chart area -->
<div class="ml-10 h-full relative">
<!-- Horizontal grid lines -->
<div class="absolute top-0 left-0 w-full h-full border-b border-gray-200">
<div class="border-t border-gray-200 h-1/4"></div>
<div class="border-t border-gray-200 h-1/4"></div>
<div class="border-t border-gray-200 h-1/4"></div>
<div class="border-t border-gray-200 h-1/4"></div>
</div>
<!-- Line chart -->
<svg class="absolute top-0 left-0 w-full h-full overflow-visible">
<path
:d="getChartPath()"
fill="none"
stroke="#3b82f6"
stroke-width="2"
></path>
<!-- Data points -->
<circle
v-for="(point, i) in chartPoints"
:key="i"
:cx="point.x"
:cy="point.y"
r="3"
fill="#3b82f6"
></circle>
</svg>
</div>
</div>
<!-- X-axis labels (show some key timestamps) -->
<div class="mt-2 ml-10 flex justify-between text-xs text-gray-500">
<div v-for="(label, i) in getXAxisLabels()" :key="i">
{{ label }}
</div>
</div>
</div>
</template>
</rs-card>
</div>
</div>
</template>
<script setup>
import { onMounted } from "vue";
definePageMeta({
title: "Performance",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
{
name: "Performance",
path: "/notification/queue/performance",
},
],
});
// Reactive state
const isLoading = ref(true);
const error = ref(null);
const metrics = ref({
throughput: "0",
uptime: "0",
workers: "0",
queueLoad: "0",
});
const throughput = ref({
current: "0",
peak: "0",
average: "0",
});
const systemStatus = ref({
uptimeToday: "0",
responseTime: "0",
errorRate: "0",
});
// Historical data state
const isLoadingHistory = ref(true);
const historyError = ref(null);
const historicalData = ref({
dataPoints: [],
summary: {
max: "0",
avg: "0",
trend: "stable", // 'increasing', 'decreasing', or 'stable'
},
});
const historyFilter = ref({
metric: "throughput",
period: "hour",
});
// Fetch metrics from API
const fetchMetrics = async () => {
try {
isLoading.value = true;
error.value = null;
const { data } = await useFetch("/api/notifications/queue/performance", {
method: "GET",
});
if (!data.value) {
throw new Error("Failed to fetch metrics data");
}
if (data.value?.success) {
const performanceData = data.value.data;
// Update metrics with fetched data
metrics.value = performanceData.metrics || {
throughput: "0",
uptime: "0",
workers: "0",
queueLoad: "0",
};
// Update throughput data
throughput.value = performanceData.throughput || {
current: "0",
peak: "0",
average: "0",
};
// Update system status
systemStatus.value = performanceData.systemStatus || {
uptimeToday: "0",
responseTime: "0",
errorRate: "0",
};
} else {
throw new Error(data.value?.statusMessage || "Failed to fetch metrics");
}
} catch (err) {
console.error("Error fetching metrics:", err);
error.value = err.message || "Failed to load performance metrics";
} finally {
isLoading.value = false;
}
};
// Refresh metrics
const refreshMetrics = () => {
fetchMetrics();
};
// Fetch historical data
const fetchHistoricalData = async () => {
isLoadingHistory.value = true;
historyError.value = null;
try {
const { data } = await useFetch("/api/notifications/queue/history", {
method: "GET",
query: {
metric: historyFilter.value.metric,
period: historyFilter.value.period,
},
});
if (!data.value) {
throw new Error("Failed to fetch historical data");
}
if (data.value?.success) {
historicalData.value = data.value.data;
} else {
throw new Error(data.value?.statusMessage || "Failed to fetch historical data");
}
} catch (err) {
console.error("Error fetching historical data:", err);
historyError.value = err.message || "Failed to load historical data";
} finally {
isLoadingHistory.value = false;
}
};
// Get chart path for the selected metric
const getChartPath = () => {
if (!historicalData.value.dataPoints || historicalData.value.dataPoints.length < 2) {
return "M 0 0"; // Return an empty path if no data points
}
const points = historicalData.value.dataPoints;
const maxVal = historicalData.value.summary.max || 1;
const width = 100 * (points.length - 1); // Total width based on points
const path = [];
points.forEach((point, i) => {
// Calculate x position (evenly spaced)
const x = (i / (points.length - 1)) * width;
// Calculate y position (inverted, as SVG y=0 is top)
// Scale value to fit in the chart height (0-100%)
const y = 100 - ((point.value / maxVal) * 100);
if (i === 0) {
path.push(`M ${x} ${y}`);
} else {
path.push(`L ${x} ${y}`);
}
});
return path.join(" ");
};
// Get data points for the chart
const chartPoints = computed(() => {
if (!historicalData.value.dataPoints) {
return [];
}
const points = historicalData.value.dataPoints;
const maxVal = historicalData.value.summary.max || 1;
const width = 100 * (points.length - 1); // Total width based on points
return points.map((point, i) => ({
x: (i / (points.length - 1)) * width,
y: 100 - ((point.value / maxVal) * 100),
}));
});
// Get Y-axis labels
const getYAxisLabels = () => {
if (!historicalData.value.dataPoints) {
return [];
}
const minY = Math.min(...historicalData.value.dataPoints.map(p => p.y));
const maxY = Math.max(...historicalData.value.dataPoints.map(p => p.y));
const yRange = maxY - minY;
const labels = [];
if (yRange > 0) {
const step = yRange / 4;
for (let i = 0; i <= 4; i++) {
labels.push(Math.ceil(minY + i * step));
}
} else {
labels.push(minY);
labels.push(0);
}
return labels;
};
// Get X-axis labels
const getXAxisLabels = () => {
if (!historicalData.value.dataPoints || historicalData.value.dataPoints.length === 0) {
return [];
}
const points = historicalData.value.dataPoints;
const numLabels = 5; // Number of labels to show
const step = Math.max(1, Math.floor(points.length / (numLabels - 1)));
const labels = [];
for (let i = 0; i < points.length; i += step) {
if (labels.length < numLabels) {
const date = new Date(points[i].timestamp);
// Format based on the period
let label;
switch (historyFilter.value.period) {
case 'hour':
label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
break;
case 'day':
label = date.toLocaleTimeString([], { hour: '2-digit' }) + 'h';
break;
case 'week':
case 'month':
label = date.toLocaleDateString([], { day: 'numeric', month: 'short' });
break;
}
labels.push(label);
}
}
// Make sure we add the last point
if (labels.length < numLabels) {
const date = new Date(points[points.length - 1].timestamp);
let label;
switch (historyFilter.value.period) {
case 'hour':
label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
break;
case 'day':
label = date.toLocaleTimeString([], { hour: '2-digit' }) + 'h';
break;
case 'week':
case 'month':
label = date.toLocaleDateString([], { day: 'numeric', month: 'short' });
break;
}
labels.push(label);
}
return labels;
};
// Get metric unit based on selected metric
const getMetricUnit = () => {
if (historyFilter.value.metric === "throughput") {
return "/min";
} else if (historyFilter.value.metric === "error_rate") {
return "%";
} else if (historyFilter.value.metric === "response_time") {
return "ms";
}
return "";
};
// Fetch metrics on component mount
onMounted(() => {
fetchMetrics();
fetchHistoricalData(); // Fetch initial historical data
});
// Auto-refresh every 60 seconds
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(fetchMetrics, 60000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,803 @@
<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-storage"></Icon>
<h1 class="text-xl font-bold text-primary">Queue Persistence Configuration</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
<span class="text-sm text-gray-600">Persistence Active</span>
</div>
<rs-button variant="outline" size="sm" @click="testPersistence">
<Icon class="mr-1" name="ic:outline-bug-report"></Icon>
Test Recovery
</rs-button>
</div>
</div>
</template>
<template #body>
<p class="text-gray-600">
Configure queue data persistence to ensure notifications survive system restarts and failures.
Critical for maintaining queue integrity and preventing message loss during system maintenance.
</p>
</template>
</rs-card>
<!-- Persistence Status Overview -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(metric, index) in persistenceMetrics"
: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="metric.bgColor"
>
<Icon class="text-2xl" :class="metric.iconColor" :name="metric.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight" :class="metric.valueColor">
{{ metric.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ metric.title }}
</span>
<div class="flex items-center mt-1" v-if="metric.status">
<div
class="w-2 h-2 rounded-full mr-1"
:class="{
'bg-green-500': metric.status === 'healthy',
'bg-yellow-500': metric.status === 'warning',
'bg-red-500': metric.status === 'error'
}"
></div>
<span class="text-xs capitalize" :class="{
'text-green-600': metric.status === 'healthy',
'text-yellow-600': metric.status === 'warning',
'text-red-600': metric.status === 'error'
}">
{{ metric.status }}
</span>
</div>
</div>
</div>
</rs-card>
</div>
<!-- Storage Configuration -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Primary Storage -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-database"></Icon>
<h3 class="text-lg font-semibold text-primary">Primary Storage Configuration</h3>
</div>
<rs-button variant="outline" size="sm" @click="showStorageModal = true">
<Icon class="mr-1" name="ic:outline-settings"></Icon>
Configure
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Storage Type -->
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p class="font-medium">Storage Type</p>
<p class="text-sm text-gray-600">{{ storageConfig.type }}</p>
</div>
<div class="text-right">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': storageConfig.status === 'connected',
'bg-red-100 text-red-800': storageConfig.status === 'disconnected',
'bg-yellow-100 text-yellow-800': storageConfig.status === 'reconnecting'
}"
>
{{ storageConfig.status }}
</span>
</div>
</div>
<!-- Connection Details -->
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-gray-600">Connection Pool</p>
<p class="font-bold text-blue-600">{{ storageConfig.connectionPool }}/{{ storageConfig.maxConnections }}</p>
</div>
<div class="text-center p-3 bg-green-50 rounded-lg">
<p class="text-sm text-gray-600">Response Time</p>
<p class="font-bold text-green-600">{{ storageConfig.responseTime }}ms</p>
</div>
</div>
<!-- Storage Metrics -->
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Used Space</span>
<span class="text-sm font-medium">{{ storageConfig.usedSpace }} / {{ storageConfig.totalSpace }}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full"
:style="{ width: storageConfig.usagePercentage + '%' }"
></div>
</div>
</div>
<!-- Last Backup -->
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div>
<p class="font-medium text-green-800">Last Backup</p>
<p class="text-sm text-green-600">{{ storageConfig.lastBackup }}</p>
</div>
<Icon class="text-green-600" name="ic:outline-backup"></Icon>
</div>
</div>
</template>
</rs-card>
<!-- Backup & Recovery -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-backup"></Icon>
<h3 class="text-lg font-semibold text-primary">Backup & Recovery</h3>
</div>
<rs-button variant="outline" size="sm" @click="createBackup">
<Icon class="mr-1" name="ic:outline-backup"></Icon>
Create Backup
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Backup Schedule -->
<div class="p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="font-medium">Automatic Backups</p>
<div class="flex items-center">
<input
type="checkbox"
v-model="backupConfig.autoBackupEnabled"
class="mr-2"
@change="updateBackupConfig"
>
<span class="text-sm">{{ backupConfig.autoBackupEnabled ? 'Enabled' : 'Disabled' }}</span>
</div>
</div>
<p class="text-sm text-gray-600">
Frequency: {{ backupConfig.frequency }} |
Retention: {{ backupConfig.retention }} days
</p>
</div>
<!-- Recent Backups -->
<div>
<h4 class="font-medium text-gray-700 mb-2">Recent Backups</h4>
<div class="space-y-2">
<div
v-for="(backup, index) in recentBackups"
:key="index"
class="flex items-center justify-between p-2 bg-gray-50 rounded"
>
<div class="flex items-center">
<Icon
class="mr-2 text-sm"
:class="{
'text-green-500': backup.status === 'completed',
'text-yellow-500': backup.status === 'in-progress',
'text-red-500': backup.status === 'failed'
}"
:name="backup.status === 'completed' ? 'ic:outline-check-circle' :
backup.status === 'in-progress' ? 'ic:outline-hourglass-empty' :
'ic:outline-error'"
></Icon>
<div>
<p class="text-sm font-medium">{{ backup.name }}</p>
<p class="text-xs text-gray-500">{{ backup.size }} {{ backup.timestamp }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<rs-button
variant="outline"
size="xs"
@click="downloadBackup(backup)"
:disabled="backup.status !== 'completed'"
>
<Icon class="text-xs" name="ic:outline-download"></Icon>
</rs-button>
<rs-button
variant="outline"
size="xs"
@click="restoreBackup(backup)"
:disabled="backup.status !== 'completed'"
>
<Icon class="text-xs" name="ic:outline-restore"></Icon>
</rs-button>
</div>
</div>
</div>
</div>
<!-- Recovery Test -->
<div class="p-3 bg-yellow-50 rounded-lg">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-yellow-800">Recovery Test</p>
<p class="text-sm text-yellow-600">Last test: {{ recoveryTest.lastTest }}</p>
</div>
<rs-button
variant="outline"
size="sm"
@click="runRecoveryTest"
>
Run Test
</rs-button>
</div>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Queue Recovery Status -->
<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-restore"></Icon>
<h3 class="text-lg font-semibold text-primary">Queue Recovery Status</h3>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600">Last System Restart: {{ lastSystemRestart }}</span>
<rs-button variant="outline" size="sm" @click="showRecoveryDetails = true">
<Icon class="mr-1" name="ic:outline-info"></Icon>
View Details
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Recovery Statistics -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Recovery Statistics</h4>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Jobs Recovered</span>
<span class="font-medium text-green-600">{{ recoveryStats.jobsRecovered.toLocaleString() }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Jobs Lost</span>
<span class="font-medium text-red-600">{{ recoveryStats.jobsLost }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Recovery Time</span>
<span class="font-medium">{{ recoveryStats.recoveryTime }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Success Rate</span>
<span class="font-medium text-blue-600">{{ recoveryStats.successRate }}%</span>
</div>
</div>
</div>
<!-- Recovery Timeline -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Recovery Timeline</h4>
<div class="space-y-3">
<div
v-for="(event, index) in recoveryTimeline"
:key="index"
class="flex items-start"
>
<div
class="w-3 h-3 rounded-full mt-1 mr-3"
:class="{
'bg-green-500': event.status === 'completed',
'bg-yellow-500': event.status === 'in-progress',
'bg-red-500': event.status === 'failed'
}"
></div>
<div>
<p class="text-sm font-medium">{{ event.action }}</p>
<p class="text-xs text-gray-500">{{ event.timestamp }}</p>
</div>
</div>
</div>
</div>
<!-- Queue State -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Current Queue State</h4>
<div class="space-y-3">
<div
v-for="(queue, index) in queueStates"
:key="index"
class="p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium">{{ queue.name }}</span>
<span
class="text-xs px-2 py-1 rounded-full"
:class="{
'bg-green-100 text-green-800': queue.status === 'healthy',
'bg-yellow-100 text-yellow-800': queue.status === 'recovering',
'bg-red-100 text-red-800': queue.status === 'error'
}"
>
{{ queue.status }}
</span>
</div>
<div class="flex justify-between text-xs text-gray-600">
<span>{{ queue.count }} jobs</span>
<span>{{ queue.lastProcessed }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Persistence Configuration -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-settings"></Icon>
<h3 class="text-lg font-semibold text-primary">Persistence Settings</h3>
</div>
<rs-button @click="savePersistenceConfig">
<Icon class="mr-1" name="ic:outline-save"></Icon>
Save Configuration
</rs-button>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- General Settings -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">General Settings</h4>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Persistence Mode</label>
<select v-model="persistenceConfig.mode" class="w-full p-2 border border-gray-300 rounded-md">
<option value="immediate">Immediate (Every job)</option>
<option value="batch">Batch (Every N jobs)</option>
<option value="interval">Interval (Every N seconds)</option>
<option value="hybrid">Hybrid (Immediate + Batch)</option>
</select>
</div>
<div v-if="persistenceConfig.mode === 'batch'">
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Size</label>
<input
type="number"
v-model="persistenceConfig.batchSize"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
</div>
<div v-if="persistenceConfig.mode === 'interval'">
<label class="block text-sm font-medium text-gray-700 mb-2">Interval (seconds)</label>
<input
type="number"
v-model="persistenceConfig.interval"
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">Data Retention (days)</label>
<input
type="number"
v-model="persistenceConfig.retentionDays"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
<p class="text-xs text-gray-500 mt-1">How long to keep completed job data</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
v-model="persistenceConfig.compressData"
class="mr-2"
>
<span class="text-sm text-gray-700">Enable data compression</span>
</div>
</div>
<!-- Recovery Settings -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Recovery Settings</h4>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Recovery Strategy</label>
<select v-model="persistenceConfig.recoveryStrategy" class="w-full p-2 border border-gray-300 rounded-md">
<option value="full">Full Recovery (All jobs)</option>
<option value="priority">Priority Recovery (High priority first)</option>
<option value="recent">Recent Recovery (Last N hours)</option>
<option value="selective">Selective Recovery (Manual selection)</option>
</select>
</div>
<div v-if="persistenceConfig.recoveryStrategy === 'recent'">
<label class="block text-sm font-medium text-gray-700 mb-2">Recovery Window (hours)</label>
<input
type="number"
v-model="persistenceConfig.recoveryWindow"
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 Recovery Time (seconds)</label>
<input
type="number"
v-model="persistenceConfig.maxRecoveryTime"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
<p class="text-xs text-gray-500 mt-1">Maximum time allowed for recovery process</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
v-model="persistenceConfig.autoRecovery"
class="mr-2"
>
<span class="text-sm text-gray-700">Enable automatic recovery on startup</span>
</div>
<div class="flex items-center">
<input
type="checkbox"
v-model="persistenceConfig.validateRecovery"
class="mr-2"
>
<span class="text-sm text-gray-700">Validate recovered jobs before processing</span>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Storage Configuration Modal -->
<rs-modal v-model="showStorageModal" title="Storage Configuration">
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Storage Type</label>
<select v-model="storageConfig.type" class="w-full p-2 border border-gray-300 rounded-md">
<option value="Redis">Redis</option>
<option value="PostgreSQL">PostgreSQL</option>
<option value="MongoDB">MongoDB</option>
<option value="MySQL">MySQL</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Connection String</label>
<input
type="text"
v-model="storageConfig.connectionString"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="redis://localhost:6379"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Connections</label>
<input
type="number"
v-model="storageConfig.maxConnections"
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">Connection Timeout (ms)</label>
<input
type="number"
v-model="storageConfig.connectionTimeout"
class="w-full p-2 border border-gray-300 rounded-md"
min="100"
>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<rs-button variant="outline" @click="showStorageModal = false">
Cancel
</rs-button>
<rs-button @click="saveStorageConfig">
Save Configuration
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Queue Persistence",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Persistence",
path: "/notification/queue-scheduler/persistence",
},
],
});
// Reactive data
const showStorageModal = ref(false);
const showRecoveryDetails = ref(false);
// Persistence metrics
const persistenceMetrics = ref([
{
title: "Storage Health",
value: "Healthy",
icon: "ic:outline-health-and-safety",
bgColor: "bg-green-100",
iconColor: "text-green-600",
valueColor: "text-green-600",
status: "healthy"
},
{
title: "Persisted Jobs",
value: "847,293",
icon: "ic:outline-storage",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
valueColor: "text-blue-600"
},
{
title: "Recovery Rate",
value: "99.97%",
icon: "ic:outline-restore",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
valueColor: "text-purple-600",
status: "healthy"
},
{
title: "Storage Usage",
value: "67%",
icon: "ic:outline-pie-chart",
bgColor: "bg-yellow-100",
iconColor: "text-yellow-600",
valueColor: "text-yellow-600",
status: "warning"
}
]);
// Storage configuration
const storageConfig = ref({
type: "Redis",
status: "connected",
connectionPool: 8,
maxConnections: 20,
responseTime: 2.3,
usedSpace: "2.4 GB",
totalSpace: "10 GB",
usagePercentage: 67,
lastBackup: "2 hours ago",
connectionString: "redis://localhost:6379",
connectionTimeout: 5000
});
// Backup configuration
const backupConfig = ref({
autoBackupEnabled: true,
frequency: "Every 6 hours",
retention: 30
});
// Recent backups
const recentBackups = ref([
{
name: "queue-backup-2024-01-15-14-30",
size: "1.2 GB",
timestamp: "2 hours ago",
status: "completed"
},
{
name: "queue-backup-2024-01-15-08-30",
size: "1.1 GB",
timestamp: "8 hours ago",
status: "completed"
},
{
name: "queue-backup-2024-01-15-02-30",
size: "1.0 GB",
timestamp: "14 hours ago",
status: "completed"
},
{
name: "queue-backup-2024-01-14-20-30",
size: "987 MB",
timestamp: "20 hours ago",
status: "completed"
}
]);
// Recovery test
const recoveryTest = ref({
lastTest: "3 days ago",
status: "passed"
});
// System restart info
const lastSystemRestart = ref("5 days ago");
// Recovery statistics
const recoveryStats = ref({
jobsRecovered: 15847,
jobsLost: 3,
recoveryTime: "2.3 seconds",
successRate: 99.97
});
// Recovery timeline
const recoveryTimeline = ref([
{
action: "System startup detected",
timestamp: "5 days ago, 09:15:23",
status: "completed"
},
{
action: "Storage connection established",
timestamp: "5 days ago, 09:15:24",
status: "completed"
},
{
action: "Queue data recovery initiated",
timestamp: "5 days ago, 09:15:25",
status: "completed"
},
{
action: "15,847 jobs recovered successfully",
timestamp: "5 days ago, 09:15:27",
status: "completed"
},
{
action: "Queue processing resumed",
timestamp: "5 days ago, 09:15:28",
status: "completed"
}
]);
// Queue states
const queueStates = ref([
{
name: "High Priority",
count: 234,
status: "healthy",
lastProcessed: "2 seconds ago"
},
{
name: "Medium Priority",
count: 1847,
status: "healthy",
lastProcessed: "1 second ago"
},
{
name: "Low Priority",
count: 3421,
status: "healthy",
lastProcessed: "5 seconds ago"
},
{
name: "Bulk Operations",
count: 2502,
status: "recovering",
lastProcessed: "30 seconds ago"
}
]);
// Persistence configuration
const persistenceConfig = ref({
mode: "hybrid",
batchSize: 100,
interval: 30,
retentionDays: 30,
compressData: true,
recoveryStrategy: "priority",
recoveryWindow: 24,
maxRecoveryTime: 300,
autoRecovery: true,
validateRecovery: true
});
// Methods
const testPersistence = () => {
console.log('Running persistence test...');
// Simulate persistence test
};
const createBackup = () => {
console.log('Creating backup...');
// Add new backup to the list
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-');
recentBackups.value.unshift({
name: `queue-backup-${timestamp}`,
size: "1.3 GB",
timestamp: "Just now",
status: "in-progress"
});
// Simulate completion after 3 seconds
setTimeout(() => {
recentBackups.value[0].status = "completed";
}, 3000);
};
const downloadBackup = (backup) => {
console.log('Downloading backup:', backup.name);
// Simulate download
};
const restoreBackup = (backup) => {
console.log('Restoring backup:', backup.name);
// Simulate restore
};
const runRecoveryTest = () => {
console.log('Running recovery test...');
recoveryTest.value.lastTest = "Just now";
// Simulate test
};
const updateBackupConfig = () => {
console.log('Updating backup configuration:', backupConfig.value);
// Save backup config
};
const savePersistenceConfig = () => {
console.log('Saving persistence configuration:', persistenceConfig.value);
// Save persistence config
};
const saveStorageConfig = () => {
console.log('Saving storage configuration:', storageConfig.value);
showStorageModal.value = false;
// Save storage config
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,665 @@
<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-priority-high"></Icon>
<h1 class="text-xl font-bold text-primary">Priority Queue Management</h1>
</div>
<rs-button @click="showCreatePriorityModal = true">
<Icon class="mr-1" name="ic:outline-add"></Icon>
Create Priority Level
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">
Manage different priority levels for notifications to ensure critical messages are processed first.
Higher priority notifications will be processed before lower priority ones in the queue.
</p>
</template>
</rs-card>
<!-- Priority Level 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 priorityStats"
: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-2xl leading-tight" :class="stat.valueColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Priority Levels Configuration -->
<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-settings"></Icon>
<h3 class="text-lg font-semibold text-primary">Priority Levels</h3>
</div>
<div class="flex items-center gap-3">
<rs-button variant="outline" size="sm" @click="refreshPriorityLevels">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
<rs-button variant="outline" size="sm" @click="showBulkEditModal = true">
<Icon class="mr-1" name="ic:outline-edit"></Icon>
Bulk Edit
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="(priority, index) in priorityLevels"
:key="index"
class="flex items-center justify-between p-4 border rounded-lg"
:class="{
'border-red-200 bg-red-50': priority.level === 'critical',
'border-orange-200 bg-orange-50': priority.level === 'high',
'border-yellow-200 bg-yellow-50': priority.level === 'medium',
'border-blue-200 bg-blue-50': priority.level === 'low',
'border-gray-200 bg-gray-50': priority.level === 'bulk'
}"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div
class="w-4 h-4 rounded-full"
:class="{
'bg-red-500': priority.level === 'critical',
'bg-orange-500': priority.level === 'high',
'bg-yellow-500': priority.level === 'medium',
'bg-blue-500': priority.level === 'low',
'bg-gray-500': priority.level === 'bulk'
}"
></div>
<span class="font-medium text-lg">{{ priority.name }}</span>
<span class="text-sm text-gray-500">({{ priority.level }})</span>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-sm text-gray-600">Weight</p>
<p class="font-bold">{{ priority.weight }}</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Queue Count</p>
<p class="font-bold">{{ priority.queueCount.toLocaleString() }}</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Avg Processing</p>
<p class="font-bold">{{ priority.avgProcessingTime }}</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Status</p>
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': priority.status === 'active',
'bg-red-100 text-red-800': priority.status === 'paused',
'bg-yellow-100 text-yellow-800': priority.status === 'throttled'
}"
>
{{ priority.status }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-button
variant="outline"
size="sm"
@click="editPriority(priority)"
>
<Icon class="mr-1" name="ic:outline-edit"></Icon>
Edit
</rs-button>
<rs-button
variant="outline"
size="sm"
:class="priority.status === 'active' ? 'text-red-600' : 'text-green-600'"
@click="togglePriorityStatus(priority)"
>
<Icon
class="mr-1"
:name="priority.status === 'active' ? 'ic:outline-pause' : 'ic:outline-play-arrow'"
></Icon>
{{ priority.status === 'active' ? 'Pause' : 'Resume' }}
</rs-button>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Queue Processing Order -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-sort"></Icon>
<h3 class="text-lg font-semibold text-primary">Processing Order Visualization</h3>
</div>
</template>
<template #body>
<div class="space-y-4">
<p class="text-gray-600 mb-4">
Notifications are processed in the following order based on priority weights:
</p>
<!-- Processing Flow -->
<div class="flex items-center justify-between bg-gray-50 p-4 rounded-lg">
<div
v-for="(level, index) in sortedPriorityLevels"
:key="index"
class="flex flex-col items-center"
>
<div
class="w-16 h-16 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
:class="{
'bg-red-500': level.level === 'critical',
'bg-orange-500': level.level === 'high',
'bg-yellow-500': level.level === 'medium',
'bg-blue-500': level.level === 'low',
'bg-gray-500': level.level === 'bulk'
}"
>
{{ level.weight }}
</div>
<span class="text-sm font-medium">{{ level.name }}</span>
<span class="text-xs text-gray-500">{{ level.queueCount }} jobs</span>
<!-- Arrow -->
<Icon
v-if="index < sortedPriorityLevels.length - 1"
class="text-gray-400 mt-2"
name="ic:outline-arrow-forward"
></Icon>
</div>
</div>
<!-- Processing Rules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<div class="bg-blue-50 p-4 rounded-lg">
<h4 class="font-medium text-blue-800 mb-2">Processing Rules</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li> Higher weight = Higher priority</li>
<li> Critical jobs always processed first</li>
<li> Same priority jobs use FIFO order</li>
<li> Bulk jobs processed during low traffic</li>
</ul>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<h4 class="font-medium text-green-800 mb-2">Performance Impact</h4>
<ul class="text-sm text-green-700 space-y-1">
<li> Critical: &lt; 1 second processing</li>
<li> High: &lt; 5 seconds processing</li>
<li> Medium: &lt; 30 seconds processing</li>
<li> Low/Bulk: Best effort processing</li>
</ul>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Recent Priority Queue Activity -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h3 class="text-lg font-semibold text-primary">Recent Priority Queue Activity</h3>
</div>
<rs-button variant="outline" size="sm" @click="navigateTo('/notification/queue-scheduler/monitor')">
View Full Monitor
</rs-button>
</div>
</template>
<template #body>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Job ID
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priority
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Queue Time
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Processing Time
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(job, index) in recentJobs" :key="index">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ job.id }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-red-100 text-red-800': job.priority === 'critical',
'bg-orange-100 text-orange-800': job.priority === 'high',
'bg-yellow-100 text-yellow-800': job.priority === 'medium',
'bg-blue-100 text-blue-800': job.priority === 'low',
'bg-gray-100 text-gray-800': job.priority === 'bulk'
}"
>
{{ job.priority }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ job.type }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': job.status === 'completed',
'bg-yellow-100 text-yellow-800': job.status === 'processing',
'bg-red-100 text-red-800': job.status === 'failed',
'bg-blue-100 text-blue-800': job.status === 'queued'
}"
>
{{ job.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ job.queueTime }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ job.processingTime }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
</rs-card>
<!-- Create Priority Level Modal -->
<rs-modal v-model="showCreatePriorityModal" title="Create Priority Level">
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Name</label>
<input
type="text"
v-model="newPriority.name"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="e.g., Emergency Alerts"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Level</label>
<select v-model="newPriority.level" class="w-full p-2 border border-gray-300 rounded-md">
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="bulk">Bulk</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weight (1-100)</label>
<input
type="number"
v-model="newPriority.weight"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
max="100"
>
<p class="text-xs text-gray-500 mt-1">Higher weight = Higher priority</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
<textarea
v-model="newPriority.description"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Describe when this priority level should be used..."
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Processing Time (seconds)</label>
<input
type="number"
v-model="newPriority.maxProcessingTime"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
<p class="text-xs text-gray-500 mt-1">Maximum time allowed for processing jobs of this priority</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<rs-button variant="outline" @click="showCreatePriorityModal = false">
Cancel
</rs-button>
<rs-button @click="createPriorityLevel">
Create Priority Level
</rs-button>
</div>
</template>
</rs-modal>
<!-- Edit Priority Modal -->
<rs-modal v-model="showEditPriorityModal" title="Edit Priority Level">
<div class="space-y-6" v-if="editingPriority">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Name</label>
<input
type="text"
v-model="editingPriority.name"
class="w-full p-2 border border-gray-300 rounded-md"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weight (1-100)</label>
<input
type="number"
v-model="editingPriority.weight"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
max="100"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Processing Time (seconds)</label>
<input
type="number"
v-model="editingPriority.maxProcessingTime"
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">Status</label>
<select v-model="editingPriority.status" class="w-full p-2 border border-gray-300 rounded-md">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="throttled">Throttled</option>
</select>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<rs-button variant="outline" @click="showEditPriorityModal = false">
Cancel
</rs-button>
<rs-button @click="savePriorityChanges">
Save Changes
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Priority Queue Management",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Priority Management",
path: "/notification/queue-scheduler/priority",
},
],
});
// Reactive data
const showCreatePriorityModal = ref(false);
const showEditPriorityModal = ref(false);
const showBulkEditModal = ref(false);
const editingPriority = ref(null);
// Priority statistics
const priorityStats = ref([
{
title: "Critical Jobs",
value: "47",
icon: "ic:outline-priority-high",
bgColor: "bg-red-100",
iconColor: "text-red-600",
valueColor: "text-red-600"
},
{
title: "High Priority",
value: "234",
icon: "ic:outline-trending-up",
bgColor: "bg-orange-100",
iconColor: "text-orange-600",
valueColor: "text-orange-600"
},
{
title: "Medium Priority",
value: "1,847",
icon: "ic:outline-remove",
bgColor: "bg-yellow-100",
iconColor: "text-yellow-600",
valueColor: "text-yellow-600"
},
{
title: "Low/Bulk Priority",
value: "5,923",
icon: "ic:outline-trending-down",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
valueColor: "text-blue-600"
}
]);
// Priority levels configuration
const priorityLevels = ref([
{
name: "Emergency Alerts",
level: "critical",
weight: 100,
queueCount: 47,
avgProcessingTime: "0.8s",
status: "active",
maxProcessingTime: 5,
description: "System emergencies and critical security alerts"
},
{
name: "Real-time Notifications",
level: "high",
weight: 80,
queueCount: 234,
avgProcessingTime: "2.1s",
status: "active",
maxProcessingTime: 10,
description: "Time-sensitive notifications like OTP, payment confirmations"
},
{
name: "Standard Notifications",
level: "medium",
weight: 50,
queueCount: 1847,
avgProcessingTime: "5.3s",
status: "active",
maxProcessingTime: 30,
description: "Regular app notifications and updates"
},
{
name: "Marketing Messages",
level: "low",
weight: 30,
queueCount: 3421,
avgProcessingTime: "12.7s",
status: "active",
maxProcessingTime: 60,
description: "Promotional content and marketing campaigns"
},
{
name: "Bulk Operations",
level: "bulk",
weight: 10,
queueCount: 2502,
avgProcessingTime: "45.2s",
status: "throttled",
maxProcessingTime: 300,
description: "Large batch operations and system maintenance"
}
]);
// New priority form
const newPriority = ref({
name: "",
level: "medium",
weight: 50,
description: "",
maxProcessingTime: 30
});
// Computed sorted priority levels
const sortedPriorityLevels = computed(() => {
return [...priorityLevels.value].sort((a, b) => b.weight - a.weight);
});
// Recent jobs data
const recentJobs = ref([
{
id: "job-001",
priority: "critical",
type: "Security Alert",
status: "completed",
queueTime: "0.1s",
processingTime: "0.8s"
},
{
id: "job-002",
priority: "high",
type: "OTP SMS",
status: "completed",
queueTime: "0.3s",
processingTime: "1.2s"
},
{
id: "job-003",
priority: "medium",
type: "App Notification",
status: "processing",
queueTime: "2.1s",
processingTime: "3.4s"
},
{
id: "job-004",
priority: "low",
type: "Newsletter",
status: "queued",
queueTime: "15.2s",
processingTime: "-"
},
{
id: "job-005",
priority: "bulk",
type: "Data Export",
status: "queued",
queueTime: "45.7s",
processingTime: "-"
}
]);
// Methods
const refreshPriorityLevels = () => {
console.log('Refreshing priority levels...');
// Simulate data refresh
};
const createPriorityLevel = () => {
console.log('Creating priority level:', newPriority.value);
// Add to priority levels
priorityLevels.value.push({
...newPriority.value,
queueCount: 0,
avgProcessingTime: "0s",
status: "active"
});
// Reset form
newPriority.value = {
name: "",
level: "medium",
weight: 50,
description: "",
maxProcessingTime: 30
};
showCreatePriorityModal.value = false;
};
const editPriority = (priority) => {
editingPriority.value = { ...priority };
showEditPriorityModal.value = true;
};
const savePriorityChanges = () => {
const index = priorityLevels.value.findIndex(p => p.name === editingPriority.value.name);
if (index !== -1) {
priorityLevels.value[index] = { ...editingPriority.value };
}
showEditPriorityModal.value = false;
editingPriority.value = null;
};
const togglePriorityStatus = (priority) => {
const newStatus = priority.status === 'active' ? 'paused' : 'active';
priority.status = newStatus;
console.log(`Priority ${priority.name} status changed to ${newStatus}`);
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,768 @@
<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-speed"></Icon>
<h1 class="text-xl font-bold text-primary">Rate Limiting</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Throttles how many messages/jobs can be processed per second/minute/hour.
Avoid hitting API limits (Twilio, SendGrid) and prevent spammy behavior that can trigger blacklisting.
</p>
</template>
</rs-card>
<!-- Rate Limit 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 rateLimitStats"
: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>
<!-- Current Usage Overview -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Current Usage Overview</h3>
<div class="flex items-center gap-2">
<div class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
<span class="text-sm text-gray-600">Live Updates</span>
</div>
<rs-button variant="outline" size="sm" @click="refreshUsage">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="(usage, index) in currentUsage"
: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">
<Icon class="mr-2 text-primary" :name="usage.icon"></Icon>
<h4 class="font-semibold">{{ usage.service }}</h4>
</div>
<rs-badge :variant="getUsageVariant(usage.percentage)">
{{ usage.percentage }}%
</rs-badge>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Current Rate:</span>
<span class="font-medium">{{ usage.currentRate }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Limit:</span>
<span class="font-medium">{{ usage.limit }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Window:</span>
<span class="font-medium">{{ usage.window }}</span>
</div>
</div>
<div class="mt-3">
<rs-progress-bar
:value="usage.percentage"
:variant="usage.percentage > 80 ? 'danger' : usage.percentage > 60 ? 'warning' : 'success'"
/>
</div>
<div class="mt-2 text-xs text-gray-500">
Resets in {{ usage.resetTime }}
</div>
</div>
</div>
</template>
</rs-card>
<!-- Rate Limit Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Rate Limit Configuration</h3>
<rs-button variant="outline" size="sm" @click="showConfigModal = true">
<Icon class="mr-1" name="ic:outline-settings"></Icon>
Configure Limits
</rs-button>
</div>
</template>
<template #body>
<rs-table
:field="configTableFields"
:data="rateLimitConfigs"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{ sortable: true, filterable: false }"
advanced
/>
</template>
</rs-card>
<!-- Rate Limit Violations -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Recent Rate Limit Violations</h3>
<div class="flex items-center gap-2">
<select v-model="violationFilter" class="p-2 border border-gray-300 rounded-md text-sm">
<option value="">All Services</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push</option>
<option value="webhook">Webhook</option>
</select>
<rs-button variant="outline" size="sm" @click="refreshViolations">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<div v-if="filteredViolations.length === 0" class="text-center py-8 text-gray-500">
<Icon class="text-4xl mb-2" name="ic:outline-check-circle"></Icon>
<p>No rate limit violations in the selected timeframe</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(violation, index) in filteredViolations"
:key="index"
class="border border-red-200 bg-red-50 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Icon class="mr-2 text-red-600" name="ic:outline-warning"></Icon>
<span class="font-semibold text-red-800">{{ violation.service }} Rate Limit Exceeded</span>
</div>
<span class="text-sm text-red-600">{{ violation.timestamp }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span class="text-red-700">Attempted Rate:</span>
<span class="ml-1 font-medium">{{ violation.attemptedRate }}</span>
</div>
<div>
<span class="text-red-700">Limit:</span>
<span class="ml-1 font-medium">{{ violation.limit }}</span>
</div>
<div>
<span class="text-red-700">Messages Dropped:</span>
<span class="ml-1 font-medium">{{ violation.droppedMessages }}</span>
</div>
</div>
<p class="text-sm text-red-700 mt-2">{{ violation.description }}</p>
</div>
</div>
</template>
</rs-card>
<!-- Rate Limit Analytics -->
<rs-card class="mb-6">
<template #header>
<h3 class="text-lg font-semibold text-primary">Rate Limit Analytics</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Usage Trends -->
<div>
<h4 class="font-semibold mb-4">Usage Trends (Last 24 Hours)</h4>
<div class="space-y-3">
<div
v-for="(trend, index) in usageTrends"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center">
<Icon class="mr-2 text-primary" :name="trend.icon"></Icon>
<div>
<p class="font-medium">{{ trend.service }}</p>
<p class="text-sm text-gray-600">Peak: {{ trend.peak }}</p>
</div>
</div>
<div class="text-right">
<p class="font-medium">{{ trend.average }}</p>
<p class="text-sm text-gray-600">Average</p>
</div>
</div>
</div>
</div>
<!-- Efficiency Metrics -->
<div>
<h4 class="font-semibold mb-4">Efficiency Metrics</h4>
<div class="space-y-4">
<div class="bg-green-50 border border-green-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-green-600" name="ic:outline-trending-up"></Icon>
<span class="font-medium text-green-800">Throughput Optimization</span>
</div>
<p class="text-sm text-green-700">
Current efficiency: {{ efficiencyMetrics.throughputOptimization }}%
</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-blue-600" name="ic:outline-schedule"></Icon>
<span class="font-medium text-blue-800">Queue Utilization</span>
</div>
<p class="text-sm text-blue-700">
Average queue utilization: {{ efficiencyMetrics.queueUtilization }}%
</p>
</div>
<div class="bg-purple-50 border border-purple-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-purple-600" name="ic:outline-timer"></Icon>
<span class="font-medium text-purple-800">Response Time</span>
</div>
<p class="text-sm text-purple-700">
Average response time: {{ efficiencyMetrics.responseTime }}ms
</p>
</div>
<div class="bg-orange-50 border border-orange-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-orange-600" name="ic:outline-error"></Icon>
<span class="font-medium text-orange-800">Error Rate</span>
</div>
<p class="text-sm text-orange-700">
Rate limit errors: {{ efficiencyMetrics.errorRate }}%
</p>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Rate Limit Testing -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">Rate Limit Testing</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Service to Test</label>
<select v-model="testConfig.service" class="w-full p-2 border border-gray-300 rounded-md">
<option value="">Select service</option>
<option value="email">Email (SendGrid)</option>
<option value="sms">SMS (Twilio)</option>
<option value="push">Push (Firebase)</option>
<option value="webhook">Webhook</option>
</select>
</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">Messages per Second</label>
<input
v-model.number="testConfig.rate"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="10"
min="1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Duration (seconds)</label>
<input
v-model.number="testConfig.duration"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="60"
min="1"
max="300"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Test Message</label>
<textarea
v-model="testConfig.message"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Test message content"
></textarea>
</div>
<rs-button
@click="startRateLimitTest"
variant="primary"
class="w-full"
:disabled="testRunning"
>
<Icon class="mr-1" :name="testRunning ? 'ic:outline-stop' : 'ic:outline-play-arrow'"></Icon>
{{ testRunning ? 'Test Running...' : 'Start Rate Limit Test' }}
</rs-button>
</div>
<div class="space-y-4">
<h4 class="font-semibold">Test Results</h4>
<div v-if="testResults.length === 0" class="text-center text-gray-500 py-8">
<Icon class="text-4xl mb-2" name="ic:outline-science"></Icon>
<p>Run a test to see results</p>
</div>
<div v-else class="space-y-3 max-h-60 overflow-y-auto">
<div
v-for="(result, index) in testResults"
:key="index"
class="border border-gray-200 rounded p-3"
>
<div class="flex justify-between items-start mb-2">
<span class="font-medium">{{ result.service }}</span>
<span :class="{
'text-green-600': result.status === 'success',
'text-red-600': result.status === 'rate_limited',
'text-yellow-600': result.status === 'warning'
}" class="text-sm font-medium">{{ result.status }}</span>
</div>
<div class="text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-600">Messages Sent:</span>
<span>{{ result.messagesSent }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Rate Achieved:</span>
<span>{{ result.rateAchieved }}/sec</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Errors:</span>
<span>{{ result.errors }}</span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">{{ result.timestamp }}</p>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Configuration Modal -->
<rs-modal v-model="showConfigModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Rate Limit Configuration</h3>
</template>
<template #body>
<div class="space-y-6">
<div
v-for="(config, index) in editableConfigs"
: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.service }} 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">Messages per Second</label>
<input
v-model.number="config.perSecond"
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">Messages per Minute</label>
<input
v-model.number="config.perMinute"
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">Messages per Hour</label>
<input
v-model.number="config.perHour"
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">Burst Limit</label>
<input
v-model.number="config.burstLimit"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
</div>
<div class="mt-4">
<label class="flex items-center">
<input
v-model="config.enabled"
type="checkbox"
class="mr-2"
/>
<span class="text-sm font-medium text-gray-700">Enable rate limiting for this service</span>
</label>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showConfigModal = false">Cancel</rs-button>
<rs-button @click="saveRateLimitConfig" variant="primary">Save Configuration</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Rate Limiting",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Rate Limiting",
path: "/notification/queue-scheduler/rate-limit",
},
],
});
// Reactive data
const showConfigModal = ref(false);
const violationFilter = ref('');
const testRunning = ref(false);
const testResults = ref([]);
// Statistics
const rateLimitStats = ref([
{
title: "Active Limits",
value: "12",
icon: "ic:outline-speed",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600"
},
{
title: "Messages/Hour",
value: "45.2K",
icon: "ic:outline-trending-up",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600"
},
{
title: "Violations Today",
value: "3",
icon: "ic:outline-warning",
bgColor: "bg-red-100",
iconColor: "text-red-600",
textColor: "text-red-600"
},
{
title: "Efficiency",
value: "96.8%",
icon: "ic:outline-check-circle",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600"
}
]);
// Current usage
const currentUsage = ref([
{
service: 'Email (SendGrid)',
icon: 'ic:outline-email',
currentRate: '850/hour',
limit: '1000/hour',
percentage: 85,
window: '1 hour',
resetTime: '23 minutes'
},
{
service: 'SMS (Twilio)',
icon: 'ic:outline-sms',
currentRate: '45/minute',
limit: '100/minute',
percentage: 45,
window: '1 minute',
resetTime: '32 seconds'
},
{
service: 'Push (Firebase)',
icon: 'ic:outline-notifications',
currentRate: '1200/hour',
limit: '5000/hour',
percentage: 24,
window: '1 hour',
resetTime: '45 minutes'
},
{
service: 'Webhook',
icon: 'ic:outline-webhook',
currentRate: '15/second',
limit: '20/second',
percentage: 75,
window: '1 second',
resetTime: '0.5 seconds'
}
]);
// Configuration table fields
const configTableFields = ref([
{ key: 'service', label: 'Service', sortable: true },
{ key: 'perSecond', label: 'Per Second', sortable: true },
{ key: 'perMinute', label: 'Per Minute', sortable: true },
{ key: 'perHour', label: 'Per Hour', sortable: true },
{ key: 'burstLimit', label: 'Burst Limit', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'actions', label: 'Actions', sortable: false }
]);
// Rate limit configurations
const rateLimitConfigs = ref([
{
service: 'Email (SendGrid)',
icon: 'ic:outline-email',
perSecond: 10,
perMinute: 600,
perHour: 1000,
burstLimit: 50,
enabled: true
},
{
service: 'SMS (Twilio)',
icon: 'ic:outline-sms',
perSecond: 5,
perMinute: 100,
perHour: 2000,
burstLimit: 20,
enabled: true
},
{
service: 'Push (Firebase)',
icon: 'ic:outline-notifications',
perSecond: 50,
perMinute: 1000,
perHour: 5000,
burstLimit: 200,
enabled: true
},
{
service: 'Webhook',
icon: 'ic:outline-webhook',
perSecond: 20,
perMinute: 500,
perHour: 10000,
burstLimit: 100,
enabled: true
}
].map(config => ({
...config,
status: h('span', {
class: `px-2 py-1 rounded text-xs font-medium ${
config.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`
}, config.enabled ? 'Active' : 'Disabled'),
actions: h('button', {
class: 'text-blue-600 hover:text-blue-800 text-sm',
onClick: () => editRateLimit(config)
}, 'Edit')
})));
// Editable configs for modal
const editableConfigs = ref(JSON.parse(JSON.stringify(rateLimitConfigs.value.map(c => ({
service: c.service,
icon: c.icon,
perSecond: c.perSecond,
perMinute: c.perMinute,
perHour: c.perHour,
burstLimit: c.burstLimit,
enabled: c.enabled
})))));
// Rate limit violations
const violations = ref([
{
service: 'Email',
timestamp: '2024-01-15 14:30:00',
attemptedRate: '1200/hour',
limit: '1000/hour',
droppedMessages: 45,
description: 'Newsletter campaign exceeded hourly limit during peak hours'
},
{
service: 'SMS',
timestamp: '2024-01-15 12:15:00',
attemptedRate: '150/minute',
limit: '100/minute',
droppedMessages: 23,
description: 'OTP verification burst exceeded per-minute limit'
},
{
service: 'Webhook',
timestamp: '2024-01-15 10:45:00',
attemptedRate: '25/second',
limit: '20/second',
droppedMessages: 12,
description: 'Order webhook notifications exceeded per-second limit'
}
]);
// Usage trends
const usageTrends = ref([
{
service: 'Email',
icon: 'ic:outline-email',
peak: '950/hour',
average: '650/hour'
},
{
service: 'SMS',
icon: 'ic:outline-sms',
peak: '85/minute',
average: '45/minute'
},
{
service: 'Push',
icon: 'ic:outline-notifications',
peak: '2100/hour',
average: '1200/hour'
},
{
service: 'Webhook',
icon: 'ic:outline-webhook',
peak: '18/second',
average: '12/second'
}
]);
// Efficiency metrics
const efficiencyMetrics = ref({
throughputOptimization: 96.8,
queueUtilization: 78.5,
responseTime: 245,
errorRate: 0.3
});
// Test configuration
const testConfig = ref({
service: '',
rate: 10,
duration: 60,
message: 'This is a rate limit test message'
});
// Computed filtered violations
const filteredViolations = computed(() => {
if (!violationFilter.value) {
return violations.value;
}
return violations.value.filter(v => v.service.toLowerCase() === violationFilter.value);
});
// Methods
function getUsageVariant(percentage) {
if (percentage > 80) return 'danger';
if (percentage > 60) return 'warning';
return 'success';
}
function refreshUsage() {
// Mock refresh
console.log('Refreshing usage data...');
}
function refreshViolations() {
// Mock refresh
console.log('Refreshing violations...');
}
function editRateLimit(config) {
// Find and update the editable config
const editableConfig = editableConfigs.value.find(c => c.service === config.service);
if (editableConfig) {
Object.assign(editableConfig, config);
}
showConfigModal.value = true;
}
function saveRateLimitConfig() {
// Mock save
console.log('Saving rate limit configuration...', editableConfigs.value);
showConfigModal.value = false;
}
function startRateLimitTest() {
if (!testConfig.value.service) {
return;
}
testRunning.value = true;
// Mock test execution
setTimeout(() => {
const result = {
service: testConfig.value.service,
messagesSent: Math.floor(testConfig.value.rate * testConfig.value.duration * 0.9),
rateAchieved: Math.floor(testConfig.value.rate * 0.9),
errors: Math.floor(Math.random() * 5),
status: Math.random() > 0.7 ? 'rate_limited' : 'success',
timestamp: new Date().toLocaleString()
};
testResults.value.unshift(result);
testRunning.value = false;
}, 3000);
}
</script>
<style lang="scss" scoped></style>

View 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>

View File

@@ -0,0 +1,707 @@
<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-schedule"></Icon>
<h1 class="text-xl font-bold text-primary">Timezone Handling</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Ensures messages are delivered at the right local time for each recipient.
Schedule birthday messages at 9AM local time and avoid 2AM push alerts across timezones.
</p>
</template>
</rs-card>
<!-- Current Time Display -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<rs-card
v-for="(timezone, index) in majorTimezones"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 text-center">
<div class="mb-2">
<Icon class="text-primary text-2xl" name="ic:outline-access-time"></Icon>
</div>
<h3 class="font-semibold text-lg">{{ timezone.name }}</h3>
<p class="text-2xl font-bold text-primary">{{ timezone.time }}</p>
<p class="text-sm text-gray-600">{{ timezone.zone }}</p>
</div>
</rs-card>
</div>
<!-- Timezone 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 timezoneStats"
: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>
<!-- Timezone Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Timezone Configuration</h3>
<rs-button variant="outline" size="sm" @click="showConfigModal = 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-3 gap-6">
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3 flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
Default Delivery Times
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Morning Messages:</span>
<span class="font-medium">{{ config.morningTime }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Afternoon Messages:</span>
<span class="font-medium">{{ config.afternoonTime }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Evening Messages:</span>
<span class="font-medium">{{ config.eveningTime }}</span>
</div>
</div>
</div>
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3 flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-block"></Icon>
Quiet Hours
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Start Time:</span>
<span class="font-medium">{{ config.quietHours.start }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">End Time:</span>
<span class="font-medium">{{ config.quietHours.end }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Emergency Override:</span>
<span class="font-medium">{{ config.quietHours.allowEmergency ? 'Enabled' : 'Disabled' }}</span>
</div>
</div>
</div>
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3 flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-public"></Icon>
Timezone Detection
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Auto-detect:</span>
<span class="font-medium">{{ config.autoDetect ? 'Enabled' : 'Disabled' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Fallback Timezone:</span>
<span class="font-medium">{{ config.fallbackTimezone }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Update Frequency:</span>
<span class="font-medium">{{ config.updateFrequency }}</span>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Scheduled Messages by Timezone -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Scheduled Messages by Timezone</h3>
<div class="flex items-center gap-2">
<select v-model="selectedTimezone" class="p-2 border border-gray-300 rounded-md text-sm">
<option value="">All Timezones</option>
<option v-for="tz in availableTimezones" :key="tz.value" :value="tz.value">
{{ tz.label }}
</option>
</select>
<rs-button variant="outline" size="sm" @click="refreshScheduledMessages">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<rs-table
:field="scheduledMessagesFields"
:data="filteredScheduledMessages"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{ sortable: true, filterable: false }"
advanced
/>
</template>
</rs-card>
<!-- Timezone Distribution Chart -->
<rs-card class="mb-6">
<template #header>
<h3 class="text-lg font-semibold text-primary">User Distribution by Timezone</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Chart would go here in a real implementation -->
<div class="space-y-3">
<div
v-for="(distribution, index) in timezoneDistribution"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-3" :style="{ backgroundColor: distribution.color }"></div>
<div>
<p class="font-medium">{{ distribution.timezone }}</p>
<p class="text-sm text-gray-600">{{ distribution.users }} users</p>
</div>
</div>
<div class="text-right">
<p class="font-medium">{{ distribution.percentage }}%</p>
<p class="text-sm text-gray-600">{{ distribution.currentTime }}</p>
</div>
</div>
</div>
<div class="space-y-4">
<h4 class="font-semibold">Delivery Optimization</h4>
<div class="space-y-3">
<div class="bg-green-50 border border-green-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-green-600" name="ic:outline-check-circle"></Icon>
<span class="font-medium text-green-800">Optimal Delivery Windows</span>
</div>
<p class="text-sm text-green-700">
{{ optimizationStats.optimalWindows }} messages scheduled during optimal hours
</p>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-yellow-600" name="ic:outline-warning"></Icon>
<span class="font-medium text-yellow-800">Quiet Hours Conflicts</span>
</div>
<p class="text-sm text-yellow-700">
{{ optimizationStats.quietHoursConflicts }} messages would be sent during quiet hours
</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-blue-600" name="ic:outline-info"></Icon>
<span class="font-medium text-blue-800">Timezone Coverage</span>
</div>
<p class="text-sm text-blue-700">
Messages will be delivered across {{ optimizationStats.timezoneCoverage }} timezones
</p>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Timezone Testing Tool -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">Timezone Testing Tool</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Test Message</label>
<textarea
v-model="testMessage.content"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Enter test message content"
></textarea>
</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">Scheduled Time (UTC)</label>
<input
v-model="testMessage.scheduledTime"
type="datetime-local"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
<select v-model="testMessage.type" class="w-full p-2 border border-gray-300 rounded-md">
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push Notification</option>
</select>
</div>
</div>
<rs-button @click="testTimezoneDelivery" variant="primary" class="w-full">
<Icon class="mr-1" name="ic:outline-play-arrow"></Icon>
Test Timezone Delivery
</rs-button>
</div>
<div class="space-y-4">
<h4 class="font-semibold">Delivery Preview</h4>
<div v-if="deliveryPreview.length > 0" class="space-y-2 max-h-60 overflow-y-auto">
<div
v-for="(preview, index) in deliveryPreview"
:key="index"
class="border border-gray-200 rounded p-3"
>
<div class="flex justify-between items-start mb-1">
<span class="font-medium">{{ preview.timezone }}</span>
<span class="text-sm text-gray-600">{{ preview.users }} users</span>
</div>
<div class="text-sm">
<p class="text-gray-600">Local delivery time: <span class="font-medium">{{ preview.localTime }}</span></p>
<p class="text-gray-600">Status:
<span :class="{
'text-green-600': preview.status === 'optimal',
'text-yellow-600': preview.status === 'suboptimal',
'text-red-600': preview.status === 'blocked'
}" class="font-medium">{{ preview.status }}</span>
</p>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
<Icon class="text-4xl mb-2" name="ic:outline-schedule"></Icon>
<p>Run a test to see delivery preview</p>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Configuration Modal -->
<rs-modal v-model="showConfigModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Timezone Configuration</h3>
</template>
<template #body>
<div class="space-y-6">
<div>
<h4 class="font-semibold mb-3">Default Delivery Times</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Morning Messages</label>
<input
v-model="config.morningTime"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Afternoon Messages</label>
<input
v-model="config.afternoonTime"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Evening Messages</label>
<input
v-model="config.eveningTime"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
</div>
</div>
<div>
<h4 class="font-semibold mb-3">Quiet Hours</h4>
<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">Start Time</label>
<input
v-model="config.quietHours.start"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">End Time</label>
<input
v-model="config.quietHours.end"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
</div>
<div class="mt-4">
<label class="flex items-center">
<input
v-model="config.quietHours.allowEmergency"
type="checkbox"
class="mr-2"
/>
<span class="text-sm font-medium text-gray-700">Allow emergency messages during quiet hours</span>
</label>
</div>
</div>
<div>
<h4 class="font-semibold mb-3">Timezone Detection</h4>
<div class="space-y-4">
<label class="flex items-center">
<input
v-model="config.autoDetect"
type="checkbox"
class="mr-2"
/>
<span class="text-sm font-medium text-gray-700">Auto-detect user timezones</span>
</label>
<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">Fallback Timezone</label>
<select v-model="config.fallbackTimezone" class="w-full p-2 border border-gray-300 rounded-md">
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="Europe/London">Europe/London</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Update Frequency</label>
<select v-model="config.updateFrequency" class="w-full p-2 border border-gray-300 rounded-md">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showConfigModal = false">Cancel</rs-button>
<rs-button @click="saveConfiguration" variant="primary">Save Configuration</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Timezone Handling",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Timezone Handling",
path: "/notification/queue-scheduler/timezone",
},
],
});
// Reactive data
const showConfigModal = ref(false);
const selectedTimezone = ref('');
const deliveryPreview = ref([]);
// Current time for major timezones
const majorTimezones = ref([
{
name: 'New York',
zone: 'America/New_York',
time: new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' })
},
{
name: 'London',
zone: 'Europe/London',
time: new Date().toLocaleTimeString('en-US', { timeZone: 'Europe/London' })
},
{
name: 'Tokyo',
zone: 'Asia/Tokyo',
time: new Date().toLocaleTimeString('en-US', { timeZone: 'Asia/Tokyo' })
}
]);
// Update times every second
setInterval(() => {
majorTimezones.value.forEach(tz => {
tz.time = new Date().toLocaleTimeString('en-US', { timeZone: tz.zone });
});
}, 1000);
// Statistics
const timezoneStats = ref([
{
title: "Active Timezones",
value: "24",
icon: "ic:outline-public",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600"
},
{
title: "Scheduled Messages",
value: "1,847",
icon: "ic:outline-schedule",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600"
},
{
title: "Optimal Deliveries",
value: "94.2%",
icon: "ic:outline-trending-up",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600"
},
{
title: "Quiet Hours Respected",
value: "99.8%",
icon: "ic:outline-nights-stay",
bgColor: "bg-orange-100",
iconColor: "text-orange-600",
textColor: "text-orange-600"
}
]);
// Configuration
const config = ref({
morningTime: '09:00',
afternoonTime: '14:00',
eveningTime: '18:00',
quietHours: {
start: '22:00',
end: '07:00',
allowEmergency: true
},
autoDetect: true,
fallbackTimezone: 'UTC',
updateFrequency: 'daily'
});
// Available timezones
const availableTimezones = ref([
{ value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
{ value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEST/AEDT)' }
]);
// Table fields for scheduled messages
const scheduledMessagesFields = ref([
{ key: 'id', label: 'Message ID', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{ key: 'timezone', label: 'Timezone', sortable: true },
{ key: 'scheduledUTC', label: 'Scheduled (UTC)', sortable: true },
{ key: 'localTime', label: 'Local Time', sortable: true },
{ key: 'recipients', label: 'Recipients', sortable: true },
{ key: 'status', label: 'Status', sortable: true }
]);
// Mock scheduled messages data
const scheduledMessages = ref([
{
id: 'msg_001',
type: 'email',
timezone: 'America/New_York',
scheduledUTC: '2024-01-15 14:00:00',
localTime: '2024-01-15 09:00:00',
recipients: 1250,
status: 'scheduled'
},
{
id: 'msg_002',
type: 'push',
timezone: 'Europe/London',
scheduledUTC: '2024-01-15 09:00:00',
localTime: '2024-01-15 09:00:00',
recipients: 890,
status: 'scheduled'
},
{
id: 'msg_003',
type: 'sms',
timezone: 'Asia/Tokyo',
scheduledUTC: '2024-01-15 00:00:00',
localTime: '2024-01-15 09:00:00',
recipients: 2100,
status: 'scheduled'
}
]);
// Timezone distribution
const timezoneDistribution = ref([
{
timezone: 'America/New_York',
users: 15420,
percentage: 32.5,
color: '#3B82F6',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' })
},
{
timezone: 'Europe/London',
users: 12890,
percentage: 27.2,
color: '#10B981',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Europe/London' })
},
{
timezone: 'Asia/Tokyo',
users: 9650,
percentage: 20.3,
color: '#F59E0B',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Asia/Tokyo' })
},
{
timezone: 'Australia/Sydney',
users: 5840,
percentage: 12.3,
color: '#EF4444',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Australia/Sydney' })
},
{
timezone: 'Others',
users: 3700,
percentage: 7.7,
color: '#8B5CF6',
currentTime: '-'
}
]);
// Optimization stats
const optimizationStats = ref({
optimalWindows: 1654,
quietHoursConflicts: 23,
timezoneCoverage: 18
});
// Test message
const testMessage = ref({
content: '',
scheduledTime: '',
type: 'email'
});
// Computed filtered scheduled messages
const filteredScheduledMessages = computed(() => {
let filtered = scheduledMessages.value;
if (selectedTimezone.value) {
filtered = filtered.filter(msg => msg.timezone === selectedTimezone.value);
}
return filtered.map(msg => ({
...msg,
status: h('span', {
class: `px-2 py-1 rounded text-xs font-medium ${
msg.status === 'scheduled' ? 'bg-blue-100 text-blue-800' :
msg.status === 'sent' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`
}, msg.status)
}));
});
// Methods
function refreshScheduledMessages() {
// Mock refresh
console.log('Refreshing scheduled messages...');
}
function testTimezoneDelivery() {
if (!testMessage.value.content || !testMessage.value.scheduledTime) {
return;
}
// Mock delivery preview generation
deliveryPreview.value = [
{
timezone: 'America/New_York',
users: 1250,
localTime: '09:00 AM',
status: 'optimal'
},
{
timezone: 'Europe/London',
localTime: '02:00 AM',
users: 890,
status: 'blocked'
},
{
timezone: 'Asia/Tokyo',
localTime: '11:00 AM',
users: 2100,
status: 'optimal'
},
{
timezone: 'Australia/Sydney',
localTime: '01:00 AM',
users: 580,
status: 'blocked'
}
];
}
function saveConfiguration() {
// Mock save
console.log('Saving timezone configuration...');
showConfigModal.value = false;
}
</script>
<style lang="scss" scoped></style>