549 lines
18 KiB
Vue
549 lines
18 KiB
Vue
<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>
|