|
|
@@ -0,0 +1,350 @@
|
|
|
+<template>
|
|
|
+ <div class="container">
|
|
|
+ <Breadcrumb :items="['网站管理', '任务队列']" />
|
|
|
+
|
|
|
+ <a-card title="队列概览" class="card-block">
|
|
|
+ <template #extra>
|
|
|
+ <a-space>
|
|
|
+ <span class="muted" v-if="summaryFetchedAt">更新于 {{ formatTime(summaryFetchedAt) }}</span>
|
|
|
+ <span class="muted">自动刷新</span>
|
|
|
+ <a-switch v-model="autoRefresh" type="round" />
|
|
|
+ <a-select v-model="refreshIntervalSec" :style="{ width: '110px' }" :disabled="!autoRefresh">
|
|
|
+ <a-option :value="3">每 3 秒</a-option>
|
|
|
+ <a-option :value="5">每 5 秒</a-option>
|
|
|
+ <a-option :value="10">每 10 秒</a-option>
|
|
|
+ <a-option :value="30">每 30 秒</a-option>
|
|
|
+ </a-select>
|
|
|
+ <a-button type="primary" :loading="summaryLoading" @click="loadSummary">
|
|
|
+ <template #icon><icon-refresh /></template>
|
|
|
+ 刷新概览
|
|
|
+ </a-button>
|
|
|
+ </a-space>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <a-table
|
|
|
+ :data="summaryRows"
|
|
|
+ :loading="summaryLoading"
|
|
|
+ :pagination="false"
|
|
|
+ :bordered="false"
|
|
|
+ row-key="name"
|
|
|
+ class="table"
|
|
|
+ >
|
|
|
+ <template #columns>
|
|
|
+ <a-table-column title="队列名称" data-index="name" :width="260" ellipsis tooltip />
|
|
|
+ <a-table-column title="待处理消息数" data-index="messageCount" :width="140">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <span v-if="record.messageCount != null">{{ record.messageCount }}</span>
|
|
|
+ <span v-else class="muted">—</span>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="消费者数" data-index="consumerCount" :width="120">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <span v-if="record.consumerCount != null">{{ record.consumerCount }}</span>
|
|
|
+ <span v-else class="muted">—</span>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="说明" data-index="error">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <a-typography-text v-if="record.error" type="danger">{{ record.error }}</a-typography-text>
|
|
|
+ <span v-else class="muted">正常</span>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ </template>
|
|
|
+ </a-table>
|
|
|
+ </a-card>
|
|
|
+
|
|
|
+ <a-card title="队列消息窥视(reject_requeue_true,不消费)" class="card-block">
|
|
|
+ <a-row :gutter="16" align="center">
|
|
|
+ <a-col :span="6">
|
|
|
+ <a-form-item label="队列" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
|
|
|
+ <a-select v-model="peekForm.queue" placeholder="选择队列">
|
|
|
+ <a-option v-for="q in ALLOWED_QUEUES" :key="q" :value="q">{{ q }}</a-option>
|
|
|
+ </a-select>
|
|
|
+ </a-form-item>
|
|
|
+ </a-col>
|
|
|
+ <a-col :span="5">
|
|
|
+ <a-form-item label="条数" :label-col-props="{ span: 8 }" :wrapper-col-props="{ span: 16 }">
|
|
|
+ <a-input-number v-model="peekForm.limit" :min="1" :max="100" mode="button" />
|
|
|
+ </a-form-item>
|
|
|
+ </a-col>
|
|
|
+ <a-col :span="13">
|
|
|
+ <a-space>
|
|
|
+ <a-button type="primary" :loading="peekLoading" @click="loadPeek">
|
|
|
+ <template #icon><icon-unordered-list /></template>
|
|
|
+ 拉取窥视
|
|
|
+ </a-button>
|
|
|
+ <span v-if="peekMeta.messageCount != null" class="meta">
|
|
|
+ 队列深度:{{ peekMeta.messageCount }},消费者:{{ peekMeta.consumerCount ?? '—' }},本次条数:{{ peekMeta.peekLimit ?? '—' }}
|
|
|
+ </span>
|
|
|
+ <span v-if="peekFetchedAt" class="muted">更新于 {{ formatTime(peekFetchedAt) }}</span>
|
|
|
+ </a-space>
|
|
|
+ </a-col>
|
|
|
+ </a-row>
|
|
|
+
|
|
|
+ <a-alert v-if="peekMeta.managementError" type="warning" style="margin-bottom: 12px">
|
|
|
+ {{ peekMeta.managementError }}
|
|
|
+ </a-alert>
|
|
|
+
|
|
|
+ <a-table
|
|
|
+ :data="peekTasks"
|
|
|
+ :loading="peekLoading"
|
|
|
+ :pagination="false"
|
|
|
+ :bordered="false"
|
|
|
+ row-key="__key"
|
|
|
+ :scroll="{ x: 1200 }"
|
|
|
+ class="table"
|
|
|
+ >
|
|
|
+ <template #columns>
|
|
|
+ <a-table-column title="再投递" data-index="redelivered" :width="88">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <a-tag v-if="record.redelivered" color="orange">是</a-tag>
|
|
|
+ <a-tag v-else color="green">否</a-tag>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="路由键" data-index="routing_key" :width="160" ellipsis tooltip />
|
|
|
+ <a-table-column title="交换机" data-index="exchange" :width="140" ellipsis tooltip />
|
|
|
+ <a-table-column title="MessageId" :width="180" ellipsis tooltip>
|
|
|
+ <template #cell="{ record }">
|
|
|
+ {{ record.properties?.messageId ?? '—' }}
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="时间戳" :width="160">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ {{ record.properties?.timestamp != null ? String(record.properties.timestamp) : '—' }}
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="负载摘要" data-index="payload">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <span class="payload-preview">{{ payloadPreview(record.payload) }}</span>
|
|
|
+ <a-button type="text" size="small" @click="openPayloadModal(record)">完整 JSON</a-button>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ </template>
|
|
|
+ </a-table>
|
|
|
+ </a-card>
|
|
|
+
|
|
|
+ <a-modal v-model:visible="payloadVisible" title="消息负载" width="800px" :footer="false" draggable>
|
|
|
+ <pre class="json-pre">{{ payloadModalText }}</pre>
|
|
|
+ </a-modal>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
|
|
|
+import { adminGetQueueTasks } from '@/api/mq'
|
|
|
+import { Notification } from '@arco-design/web-vue'
|
|
|
+
|
|
|
+/** 与后端 ALLOWED_QUEUES 保持一致,用于下拉与展示 */
|
|
|
+const ALLOWED_QUEUES = [
|
|
|
+ 'runforge_task_queue',
|
|
|
+ 'runforge_task_result_queue',
|
|
|
+ 'runforge_task_dead_queue',
|
|
|
+ 'runforge_message_queue',
|
|
|
+ 'order_payment_check',
|
|
|
+ 'mq_health_check'
|
|
|
+]
|
|
|
+
|
|
|
+const summaryLoading = ref(false)
|
|
|
+const summaryRows = ref([])
|
|
|
+const summaryFetchedAt = ref(null)
|
|
|
+
|
|
|
+const peekLoading = ref(false)
|
|
|
+const peekTasks = ref([])
|
|
|
+const peekFetchedAt = ref(null)
|
|
|
+const peekMeta = reactive({
|
|
|
+ messageCount: null,
|
|
|
+ consumerCount: null,
|
|
|
+ peekLimit: null,
|
|
|
+ managementError: null
|
|
|
+})
|
|
|
+
|
|
|
+const peekForm = reactive({
|
|
|
+ queue: 'runforge_task_queue',
|
|
|
+ limit: 30
|
|
|
+})
|
|
|
+
|
|
|
+const autoRefresh = ref(false)
|
|
|
+const refreshIntervalSec = ref(5)
|
|
|
+let pollTimer = null
|
|
|
+
|
|
|
+const payloadVisible = ref(false)
|
|
|
+const payloadModalText = ref('')
|
|
|
+
|
|
|
+const formatTime = (ts) => {
|
|
|
+ if (ts == null) return ''
|
|
|
+ return new Date(ts).toLocaleString('zh-CN', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: '2-digit',
|
|
|
+ day: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit'
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const payloadPreview = (payload) => {
|
|
|
+ if (payload == null) return '—'
|
|
|
+ const s = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
|
+ return s.length > 100 ? `${s.slice(0, 100)}…` : s
|
|
|
+}
|
|
|
+
|
|
|
+const openPayloadModal = (record) => {
|
|
|
+ try {
|
|
|
+ payloadModalText.value =
|
|
|
+ typeof record.payload === 'string'
|
|
|
+ ? record.payload
|
|
|
+ : JSON.stringify(record.payload, null, 2)
|
|
|
+ } catch {
|
|
|
+ payloadModalText.value = String(record.payload)
|
|
|
+ }
|
|
|
+ payloadVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const loadSummary = async (silent = false) => {
|
|
|
+ if (!silent) summaryLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await adminGetQueueTasks({ summary: '1' })
|
|
|
+ if (!res || res.code !== 0) {
|
|
|
+ Notification.error({
|
|
|
+ title: '获取队列概览失败',
|
|
|
+ content: res?.msg ?? '请稍后再试'
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const queues = res.data?.queues || {}
|
|
|
+ summaryRows.value = ALLOWED_QUEUES.map((name) => {
|
|
|
+ const info = queues[name] || {}
|
|
|
+ return {
|
|
|
+ name,
|
|
|
+ messageCount: info.messageCount ?? null,
|
|
|
+ consumerCount: info.consumerCount ?? null,
|
|
|
+ error: info.error
|
|
|
+ }
|
|
|
+ })
|
|
|
+ summaryFetchedAt.value = res.data?.fetchedAt ?? Date.now()
|
|
|
+ } catch (e) {
|
|
|
+ Notification.error({
|
|
|
+ title: '获取队列概览失败',
|
|
|
+ content: e.message || '请稍后再试'
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ if (!silent) summaryLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const loadPeek = async (silent = false) => {
|
|
|
+ if (!silent) peekLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await adminGetQueueTasks({
|
|
|
+ queue: peekForm.queue,
|
|
|
+ limit: peekForm.limit
|
|
|
+ })
|
|
|
+ if (!res || res.code !== 0) {
|
|
|
+ Notification.error({
|
|
|
+ title: '窥视队列失败',
|
|
|
+ content: res?.msg ?? '请稍后再试'
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const d = res.data || {}
|
|
|
+ peekMeta.messageCount = d.messageCount ?? null
|
|
|
+ peekMeta.consumerCount = d.consumerCount ?? null
|
|
|
+ peekMeta.peekLimit = d.peekLimit ?? null
|
|
|
+ peekMeta.managementError = d.managementError || null
|
|
|
+ peekFetchedAt.value = d.fetchedAt ?? Date.now()
|
|
|
+ const list = Array.isArray(d.tasks) ? d.tasks : []
|
|
|
+ peekTasks.value = list.map((row, i) => ({
|
|
|
+ ...row,
|
|
|
+ __key: `${peekForm.queue}-${peekFetchedAt.value}-${i}`
|
|
|
+ }))
|
|
|
+ } catch (e) {
|
|
|
+ Notification.error({
|
|
|
+ title: '窥视队列失败',
|
|
|
+ content: e.message || '请稍后再试'
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ if (!silent) peekLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const refreshAll = (silent = false) => {
|
|
|
+ loadSummary(silent)
|
|
|
+ loadPeek(silent)
|
|
|
+}
|
|
|
+
|
|
|
+const clearPoll = () => {
|
|
|
+ if (pollTimer) {
|
|
|
+ clearInterval(pollTimer)
|
|
|
+ pollTimer = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const setupPoll = () => {
|
|
|
+ clearPoll()
|
|
|
+ if (!autoRefresh.value) return
|
|
|
+ pollTimer = setInterval(() => {
|
|
|
+ refreshAll(true)
|
|
|
+ }, refreshIntervalSec.value * 1000)
|
|
|
+}
|
|
|
+
|
|
|
+watch([autoRefresh, refreshIntervalSec], () => {
|
|
|
+ setupPoll()
|
|
|
+ if (autoRefresh.value) refreshAll(true)
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ refreshAll(false)
|
|
|
+ setupPoll()
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ clearPoll()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.container {
|
|
|
+ padding: 0 20px 20px 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.card-block {
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.table {
|
|
|
+ margin-top: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.muted {
|
|
|
+ color: var(--color-text-3);
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.meta {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--color-text-2);
|
|
|
+}
|
|
|
+
|
|
|
+.payload-preview {
|
|
|
+ display: inline-block;
|
|
|
+ max-width: 360px;
|
|
|
+ margin-right: 8px;
|
|
|
+ vertical-align: middle;
|
|
|
+ color: var(--color-text-2);
|
|
|
+ font-size: 13px;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+
|
|
|
+.json-pre {
|
|
|
+ margin: 0;
|
|
|
+ max-height: 480px;
|
|
|
+ overflow: auto;
|
|
|
+ padding: 12px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-all;
|
|
|
+ background: var(--color-fill-2);
|
|
|
+ border-radius: var(--border-radius-medium);
|
|
|
+}
|
|
|
+</style>
|