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