| <script setup lang="ts"> |
| import { computed, ref, watch } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { Chart as ChartJS, CategoryScale, Filler, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js' |
| import { Line } from 'vue-chartjs' |
| import type { ChartComponentRef } from 'vue-chartjs' |
| import type { OpsThroughputGroupBreakdownItem, OpsThroughputPlatformBreakdownItem, OpsThroughputTrendPoint } from '@/api/admin/ops' |
| import type { ChartState } from '../types' |
| import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters' |
| import HelpTooltip from '@/components/common/HelpTooltip.vue' |
| import EmptyState from '@/components/common/EmptyState.vue' |
| import { formatNumber } from '@/utils/format' |
|
|
| ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler) |
|
|
| interface Props { |
| points: OpsThroughputTrendPoint[] |
| loading: boolean |
| timeRange: string |
| byPlatform?: OpsThroughputPlatformBreakdownItem[] |
| topGroups?: OpsThroughputGroupBreakdownItem[] |
| fullscreen?: boolean |
| } |
|
|
| const props = defineProps<Props>() |
| const { t } = useI18n() |
| const emit = defineEmits<{ |
| (e: 'selectPlatform', platform: string): void |
| (e: 'selectGroup', groupId: number): void |
| (e: 'openDetails'): void |
| }>() |
|
|
| const throughputChartRef = ref<ChartComponentRef | null>(null) |
| watch( |
| () => props.timeRange, |
| () => { |
| setTimeout(() => { |
| const chart: any = throughputChartRef.value?.chart |
| if (chart && typeof chart.resetZoom === 'function') { |
| chart.resetZoom() |
| } |
| }, 100) |
| } |
| ) |
|
|
| const isDarkMode = computed(() => document.documentElement.classList.contains('dark')) |
| const colors = computed(() => ({ |
| blue: '#3b82f6', |
| blueAlpha: '#3b82f620', |
| green: '#10b981', |
| greenAlpha: '#10b98120', |
| grid: isDarkMode.value ? '#374151' : '#f3f4f6', |
| text: isDarkMode.value ? '#9ca3af' : '#6b7280' |
| })) |
|
|
| const totalRequests = computed(() => sumNumbers(props.points.map((p) => p.request_count))) |
|
|
| const chartData = computed(() => { |
| if (!props.points.length || totalRequests.value <= 0) return null |
| return { |
| labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)), |
| datasets: [ |
| { |
| label: 'QPS', |
| data: props.points.map((p) => p.qps ?? 0), |
| borderColor: colors.value.blue, |
| backgroundColor: colors.value.blueAlpha, |
| fill: true, |
| tension: 0.4, |
| pointRadius: 0, |
| pointHitRadius: 10 |
| }, |
| { |
| label: t('admin.ops.tpsK'), |
| data: props.points.map((p) => (p.tps ?? 0) / 1000), |
| borderColor: colors.value.green, |
| backgroundColor: colors.value.greenAlpha, |
| fill: true, |
| tension: 0.4, |
| pointRadius: 0, |
| pointHitRadius: 10, |
| yAxisID: 'y1' |
| } |
| ] |
| } |
| }) |
|
|
| const state = computed<ChartState>(() => { |
| if (chartData.value) return 'ready' |
| if (props.loading) return 'loading' |
| return 'empty' |
| }) |
|
|
| const options = computed(() => { |
| const c = colors.value |
| return { |
| responsive: true, |
| maintainAspectRatio: false, |
| interaction: { intersect: false, mode: 'index' as const }, |
| plugins: { |
| legend: { |
| position: 'top' as const, |
| align: 'end' as const, |
| labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } } |
| }, |
| tooltip: { |
| backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff', |
| titleColor: isDarkMode.value ? '#f3f4f6' : '#111827', |
| bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563', |
| borderColor: c.grid, |
| borderWidth: 1, |
| padding: 10, |
| displayColors: true, |
| callbacks: { |
| label: (context: any) => { |
| let label = context.dataset.label || '' |
| if (label) label += ': ' |
| if (context.raw !== null) label += context.parsed.y.toFixed(1) |
| return label |
| } |
| } |
| }, |
| |
| zoom: { |
| pan: { enabled: true, mode: 'x' as const, modifierKey: 'ctrl' as const }, |
| zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' as const } |
| } |
| }, |
| scales: { |
| x: { |
| type: 'category' as const, |
| grid: { display: false }, |
| ticks: { |
| color: c.text, |
| font: { size: 10 }, |
| maxTicksLimit: 8, |
| autoSkip: true, |
| autoSkipPadding: 10 |
| } |
| }, |
| y: { |
| type: 'linear' as const, |
| display: true, |
| position: 'left' as const, |
| grid: { color: c.grid, borderDash: [4, 4] }, |
| ticks: { color: c.text, font: { size: 10 } } |
| }, |
| y1: { |
| type: 'linear' as const, |
| display: true, |
| position: 'right' as const, |
| grid: { display: false }, |
| ticks: { color: c.green, font: { size: 10 } } |
| } |
| } |
| } |
| }) |
|
|
| function resetZoom() { |
| const chart: any = throughputChartRef.value?.chart |
| if (chart && typeof chart.resetZoom === 'function') chart.resetZoom() |
| } |
|
|
| function downloadChart() { |
| const chart: any = throughputChartRef.value?.chart |
| if (!chart || typeof chart.toBase64Image !== 'function') return |
| const url = chart.toBase64Image('image/png', 1) |
| const a = document.createElement('a') |
| a.href = url |
| a.download = `ops-throughput-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png` |
| a.click() |
| } |
| </script> |
|
|
| <template> |
| <div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"> |
| <div class="mb-4 flex shrink-0 items-center justify-between"> |
| <h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white"> |
| <svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /> |
| </svg> |
| {{ t('admin.ops.throughputTrend') }} |
| <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" /> |
| </h3> |
| <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> |
| <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>QPS</span> |
| <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span> |
| <template v-if="!props.fullscreen"> |
| <button |
| type="button" |
| class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" |
| :disabled="state !== 'ready'" |
| :title="t('admin.ops.requestDetails.title')" |
| @click="emit('openDetails')" |
| > |
| {{ t('admin.ops.requestDetails.details') }} |
| </button> |
| <button |
| type="button" |
| class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" |
| :disabled="state !== 'ready'" |
| :title="t('admin.ops.charts.resetZoomHint')" |
| @click="resetZoom" |
| > |
| {{ t('admin.ops.charts.resetZoom') }} |
| </button> |
| <button |
| type="button" |
| class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" |
| :disabled="state !== 'ready'" |
| :title="t('admin.ops.charts.downloadChartHint')" |
| @click="downloadChart" |
| > |
| {{ t('admin.ops.charts.downloadChart') }} |
| </button> |
| </template> |
| </div> |
| </div> |
| |
| |
| <div v-if="(props.topGroups?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2"> |
| <button |
| v-for="g in props.topGroups" |
| :key="g.group_id" |
| type="button" |
| class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800" |
| @click="emit('selectGroup', g.group_id)" |
| > |
| <span class="max-w-[180px] truncate">{{ g.group_name || `#${g.group_id}` }}</span> |
| <span class="text-gray-400 dark:text-gray-500">{{ formatNumber(g.request_count) }}</span> |
| </button> |
| </div> |
| |
| <div v-else-if="(props.byPlatform?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2"> |
| <button |
| v-for="p in props.byPlatform" |
| :key="p.platform" |
| type="button" |
| class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800" |
| @click="emit('selectPlatform', p.platform)" |
| > |
| <span class="uppercase">{{ p.platform }}</span> |
| <span class="text-gray-400 dark:text-gray-500">{{ formatNumber(p.request_count) }}</span> |
| </button> |
| </div> |
| |
| <div class="min-h-0 flex-1"> |
| <Line v-if="state === 'ready' && chartData" ref="throughputChartRef" :data="chartData" :options="options" /> |
| <div v-else class="flex h-full items-center justify-center"> |
| <div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div> |
| <EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" /> |
| </div> |
| </div> |
| </div> |
| </template> |
|
|