diff --git a/frontend/src/components/layouts/MainLayout.vue b/frontend/src/components/layouts/MainLayout.vue index 6958d3f..5399b8e 100644 --- a/frontend/src/components/layouts/MainLayout.vue +++ b/frontend/src/components/layouts/MainLayout.vue @@ -11,6 +11,7 @@ import { UnorderedListOutlined, DollarOutlined, MonitorOutlined, + WarningOutlined, LogoutOutlined, SettingOutlined, KeyOutlined, @@ -80,6 +81,7 @@ const menuItems: MenuItem[] = [ { key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true }, { key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true }, { key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true }, + { key: '/failed-messages', icon: WarningOutlined, label: '失败消息', adminOnly: true }, ] const filteredMenuItems = computed(() => @@ -113,6 +115,7 @@ const breadcrumbItems = computed(() => { '/roles': '角色管理', '/route-groups': '路由组管理', '/mq-status': '队列监控', + '/failed-messages': '失败消息', '/profile': '个人信息', '/profile/password': '修改密码', } diff --git a/frontend/src/pages/failed-messages/__tests__/index.spec.ts b/frontend/src/pages/failed-messages/__tests__/index.spec.ts new file mode 100644 index 0000000..e116106 --- /dev/null +++ b/frontend/src/pages/failed-messages/__tests__/index.spec.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { useFailedMessageStore } from '@/stores/failed-message' +import type { FailedMessageRecord, FailedMessageDetail } from '@/types/api' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + }, +})) + +const mockMessages: FailedMessageRecord[] = [ + { + id: 1, + error_id: 'err_001', + data_type: 'order', + platform: 'Shopee', + platform_id: 1, + company_id: 1, + store_id: 1, + error_type: 'InvalidArgumentException', + error_message: 'Missing required field: platform_order_id', + retry_count: 3, + message_id: 'msg_abc123', + failed_at: '2026-03-19T10:30:00', + created_at: '2026-03-19T10:30:00', + }, + { + id: 2, + error_id: 'err_002', + data_type: 'product', + platform: 'Lazada', + platform_id: 2, + company_id: 1, + store_id: 2, + error_type: 'RuntimeException', + error_message: 'Database connection timeout after 30s', + retry_count: 1, + message_id: 'msg_def456', + failed_at: '2026-03-19T11:00:00', + created_at: '2026-03-19T11:00:00', + }, + { + id: 3, + error_id: 'err_003', + data_type: 'refund', + platform: 'Shopee', + platform_id: 1, + company_id: 2, + store_id: 3, + error_type: 'ValidationException', + error_message: 'Refund amount exceeds order total', + retry_count: 0, + message_id: 'msg_ghi789', + failed_at: '2026-03-20T08:15:00', + created_at: '2026-03-20T08:15:00', + }, +] + +const baseMock = mockMessages[0]! + +const mockDetail: FailedMessageDetail = { + ...baseMock, + error_code: 'ERR_MISSING_FIELD', + error_trace: '#0 /app/Entity/Parse/EntityParse.php(42)\n#1 /app/Consumer/OrderConsumer.php(58)\n#2 {main}', + original_message: { meta: { company_id: 1 }, data: [{ order_id: '123' }] }, +} + +const mockPlatforms = [ + { id: 1, name: 'Shopee' }, + { id: 2, name: 'Lazada' }, +] + +// ─── Store Tests ─────────────────────────────────────── + +describe('useFailedMessageStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('S1: starts with correct initial state', () => { + const store = useFailedMessageStore() + expect(store.messages).toEqual([]) + expect(store.loading).toBe(false) + expect(store.pagination.page).toBe(1) + expect(store.pagination.per_page).toBe(15) + expect(store.pagination.total).toBe(0) + expect(store.filters.data_type).toBeUndefined() + expect(store.filters.platform_id).toBeUndefined() + expect(store.filters.failed_at_range).toBeNull() + }) + }) + + describe('fetchMessages', () => { + it('S2: fetches messages successfully', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce({ + items: mockMessages, + total: 3, + page: 1, + per_page: 15, + }) + + const store = useFailedMessageStore() + await store.fetchMessages() + + expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', { + page: 1, + per_page: 15, + data_type: undefined, + platform_id: undefined, + failed_at_from: undefined, + failed_at_to: undefined, + }) + expect(store.messages).toEqual(mockMessages) + expect(store.pagination.total).toBe(3) + expect(store.loading).toBe(false) + }) + + it('S3: fetches with filters applied', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce({ + items: [mockMessages[0]], + total: 1, + page: 1, + per_page: 15, + }) + + const store = useFailedMessageStore() + store.filters.data_type = 'order' + store.filters.platform_id = 1 + store.filters.failed_at_range = ['2026-03-19', '2026-03-20'] + await store.fetchMessages() + + expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', { + page: 1, + per_page: 15, + data_type: 'order', + platform_id: 1, + failed_at_from: '2026-03-19', + failed_at_to: '2026-03-20', + }) + expect(store.messages).toHaveLength(1) + }) + + it('S4: handles fetch failure gracefully', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('网络超时')) + + const store = useFailedMessageStore() + await store.fetchMessages() + + expect(store.messages).toEqual([]) + expect(store.pagination.total).toBe(0) + expect(store.loading).toBe(false) + }) + }) + + describe('resetFilters', () => { + it('S5: resets all filters and page', () => { + const store = useFailedMessageStore() + store.filters.data_type = 'order' + store.filters.platform_id = 1 + store.filters.failed_at_range = ['2026-03-01', '2026-03-20'] + store.pagination.page = 3 + + store.resetFilters() + + expect(store.filters.data_type).toBeUndefined() + expect(store.filters.platform_id).toBeUndefined() + expect(store.filters.failed_at_range).toBeNull() + expect(store.pagination.page).toBe(1) + }) + }) + + describe('platformMap', () => { + it('S6: builds platformMap from loaded platforms', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockPlatforms) + + const store = useFailedMessageStore() + await store.loadLookups() + + expect(store.platformMap.get(1)).toBe('Shopee') + expect(store.platformMap.get(2)).toBe('Lazada') + }) + + it('S7: handles loadLookups failure gracefully', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败')) + + const store = useFailedMessageStore() + await store.loadLookups() + + expect(store.platforms).toEqual([]) + expect(store.platformMap.size).toBe(0) + }) + }) +}) + +// ─── Page Component Tests ────────────────────────────── + +describe('FailedMessagesPage', () => { + let wrapper: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + async function mountPage() { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockImplementation((url: string) => { + if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) + if (url === '/api/v1/failed-messages') { + return Promise.resolve({ + items: mockMessages, + total: 3, + page: 1, + per_page: 15, + }) + } + return Promise.resolve(null) + }) + + const { default: FailedMessagesPage } = await import('../index.vue') + + wrapper = mount(FailedMessagesPage, { + attachTo: document.body, + global: { + stubs: { + SearchOutlined: { template: '' }, + ReloadOutlined: { template: '' }, + EyeOutlined: { template: '' }, + }, + }, + }) + await flushPromises() + await nextTick() + return { wrapper, api } + } + + it('P1: renders page title', async () => { + await mountPage() + expect(wrapper.text()).toContain('失败消息管理') + }, 15000) + + it('P2: calls API on mount', async () => { + const { api } = await mountPage() + expect(api.get).toHaveBeenCalledWith('/api/v1/platforms') + expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', expect.any(Object)) + }) + + it('P3: renders table with message data', async () => { + await mountPage() + const html = wrapper.html() + expect(html).toContain('InvalidArgumentException') + expect(html).toContain('RuntimeException') + expect(html).toContain('Missing required field') + }) + + it('P4: renders data_type as colored tags with Chinese labels', async () => { + await mountPage() + const tags = wrapper.findAll('.ant-tag') + const tagTexts = tags.map((t) => t.text()) + expect(tagTexts).toContain('订单') + expect(tagTexts).toContain('产品') + expect(tagTexts).toContain('退款') + }) + + it('P5: displays platform name from platformMap', async () => { + await mountPage() + const html = wrapper.html() + expect(html).toContain('Shopee') + expect(html).toContain('Lazada') + }) + + it('P6: formats failed_at as YYYY-MM-DD HH:mm', async () => { + await mountPage() + const html = wrapper.html() + expect(html).toContain('2026-03-19 10:30') + expect(html).toContain('2026-03-19 11:00') + }) + + it('P7: search button triggers fetch with page=1', async () => { + const { api } = await mountPage() + const store = useFailedMessageStore() + store.pagination.page = 3 + vi.mocked(api.get).mockClear() + vi.mocked(api.get).mockResolvedValue({ + items: [], + total: 0, + page: 1, + per_page: 15, + }) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const searchBtn = buttons.find((b) => b.textContent?.trim().includes('搜索')) + expect(searchBtn).toBeDefined() + + searchBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + expect(store.pagination.page).toBe(1) + expect(api.get).toHaveBeenCalledWith('/api/v1/failed-messages', expect.any(Object)) + }) + + it('P8: reset button clears filters and fetches', async () => { + const { api } = await mountPage() + const store = useFailedMessageStore() + store.filters.data_type = 'order' + store.filters.platform_id = 1 + vi.mocked(api.get).mockClear() + vi.mocked(api.get).mockResolvedValue({ + items: mockMessages, + total: 3, + page: 1, + per_page: 15, + }) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const resetBtn = buttons.find((b) => b.textContent?.trim().includes('重置')) + expect(resetBtn).toBeDefined() + + resetBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + expect(store.filters.data_type).toBeUndefined() + expect(store.filters.platform_id).toBeUndefined() + }) + + it('P9: clicking view button opens detail drawer', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValue(mockDetail) + + const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link')) + expect(viewButtons.length).toBeGreaterThan(0) + viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const drawerBody = document.body.querySelector('.ant-drawer') + expect(drawerBody).toBeDefined() + expect(document.body.innerHTML).toContain('失败消息详情') + }) + + it('P10: detail drawer shows error_trace in pre block', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValue(mockDetail) + + const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link')) + expect(viewButtons.length).toBeGreaterThan(0) + viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const pres = document.body.querySelectorAll('pre') + const preTexts = Array.from(pres).map((p) => p.textContent) + expect(preTexts.some((t) => t?.includes('#0 /app/Entity/Parse/EntityParse.php(42)'))).toBe(true) + }) + + it('P11: detail drawer shows original_message as formatted JSON', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockResolvedValue(mockDetail) + + const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link')) + expect(viewButtons.length).toBeGreaterThan(0) + viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + const pres = document.body.querySelectorAll('pre') + const preTexts = Array.from(pres).map((p) => p.textContent) + expect(preTexts.some((t) => t?.includes('"company_id": 1'))).toBe(true) + }) + + it('P12: detail request failure shows error message', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockRejectedValue(new Error('详情加载失败')) + + const viewButtons = Array.from(document.body.querySelectorAll('.ant-btn-link')) + expect(viewButtons.length).toBeGreaterThan(0) + viewButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + // drawer should be open but no detail content + const drawerBody = document.body.querySelector('.ant-drawer') + expect(drawerBody).toBeDefined() + }) +}) diff --git a/frontend/src/pages/failed-messages/index.vue b/frontend/src/pages/failed-messages/index.vue new file mode 100644 index 0000000..6d22789 --- /dev/null +++ b/frontend/src/pages/failed-messages/index.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/src/pages/mq-status/__tests__/index.spec.ts b/frontend/src/pages/mq-status/__tests__/index.spec.ts new file mode 100644 index 0000000..2de0786 --- /dev/null +++ b/frontend/src/pages/mq-status/__tests__/index.spec.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { useMqStatusStore } from '@/stores/mq-status' +import type { MqStatusData } from '@/types/api' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + }, +})) + +const mockStatusData: MqStatusData = { + business_queues: [ + { queue: 'orders.queue', messages: 5, consumers: 2, status: 'active' }, + { queue: 'products.queue', messages: 0, consumers: 1, status: 'empty' }, + { queue: 'refunds.queue', messages: 120, consumers: 3, status: 'high_load' }, + { queue: 'inventory.queue', messages: 'N/A', consumers: 'N/A', status: 'error' }, + ], + retry_queues: [ + { queue: 'orders.retry.queue', messages: 2, consumers: 1, status: 'active' }, + { queue: 'products.retry.queue', messages: 0, consumers: 1, status: 'empty' }, + ], + error_queue: { queue: 'errors.queue', messages: 3, consumers: 1, status: 'processing' }, + summary: { total_messages: 130, total_consumers: 9 }, + fetched_at: '2026-03-20 14:30:00', +} + +const mockFilteredStatusData: MqStatusData = { + business_queues: [ + { queue: 'orders.queue', messages: 5, consumers: 2, status: 'active' }, + ], + retry_queues: [ + { queue: 'orders.retry.queue', messages: 2, consumers: 1, status: 'active' }, + ], + error_queue: [], + summary: { total_messages: 7, total_consumers: 3 }, + fetched_at: '2026-03-20 14:31:00', +} + +// ─── Store Tests ─────────────────────────────────────── + +describe('useMqStatusStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('S1: starts with correct initial state', () => { + const store = useMqStatusStore() + expect(store.statusData).toBeNull() + expect(store.loading).toBe(false) + expect(store.errorMessage).toBe('') + expect(store.queueType).toBeUndefined() + }) + }) + + describe('fetchStatus', () => { + it('S2: fetches status data successfully', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockStatusData) + + const store = useMqStatusStore() + await store.fetchStatus() + + expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', {}) + expect(store.statusData).toEqual(mockStatusData) + expect(store.loading).toBe(false) + expect(store.errorMessage).toBe('') + }) + + it('S3: fetches with queue parameter when filtered', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockFilteredStatusData) + + const store = useMqStatusStore() + store.queueType = 'orders' + await store.fetchStatus() + + expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', { queue: 'orders' }) + expect(store.statusData).toEqual(mockFilteredStatusData) + }) + + it('S4: sets errorMessage on failure', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValueOnce(new Error('连接超时')) + + const store = useMqStatusStore() + await store.fetchStatus() + + expect(store.errorMessage).toBe('连接超时') + expect(store.loading).toBe(false) + }) + }) + + describe('healthySummary', () => { + it('S5: computes healthy/total correctly', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValueOnce(mockStatusData) + + const store = useMqStatusStore() + await store.fetchStatus() + + // active(orders.queue, orders.retry.queue) + empty(products.queue, products.retry.queue) = 4 healthy + // total = 4 business + 2 retry + 1 error = 7 + expect(store.healthySummary.healthy).toBe(4) + expect(store.healthySummary.total).toBe(7) + }) + }) + + describe('resetFilter', () => { + it('S6: clears queue type filter', () => { + const store = useMqStatusStore() + store.queueType = 'orders' + store.resetFilter() + expect(store.queueType).toBeUndefined() + }) + }) +}) + +// ─── Page Component Tests ────────────────────────────── + +describe('MqStatusPage', () => { + let wrapper: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + async function mountPage(data: MqStatusData = mockStatusData) { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockResolvedValue(data) + + const { default: MqStatusPage } = await import('../index.vue') + + wrapper = mount(MqStatusPage, { + attachTo: document.body, + global: { + stubs: { + ReloadOutlined: { template: '' }, + }, + }, + }) + await flushPromises() + await nextTick() + return { wrapper, api } + } + + it('P1: renders page title and refresh button', async () => { + await mountPage() + + expect(wrapper.text()).toContain('消息队列状态') + const buttons = wrapper.findAll('.ant-btn') + const buttonTexts = buttons.map((b) => b.text()) + expect(buttonTexts.some((t) => t.includes('刷新'))).toBe(true) + }, 15000) + + it('P2: calls fetchStatus on mount', async () => { + const { api } = await mountPage() + + expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', {}) + }) + + it('P3: displays total_messages and total_consumers in stat cards', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('总消息数') + expect(html).toContain('130') + expect(html).toContain('总消费者数') + expect(html).toContain('9') + }) + + it('P4: renders business queues table', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('业务队列') + expect(html).toContain('orders.queue') + expect(html).toContain('products.queue') + expect(html).toContain('refunds.queue') + expect(html).toContain('inventory.queue') + }) + + it('P5: renders retry queues table', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('重试队列') + expect(html).toContain('orders.retry.queue') + expect(html).toContain('products.retry.queue') + }) + + it('P6: renders error queue with message count', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('错误队列') + expect(html).toContain('errors.queue') + }) + + it('P7: renders status tags with correct colors', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('活跃') + expect(html).toContain('空闲') + expect(html).toContain('高负载') + expect(html).toContain('异常') + }) + + it('P8: filter change triggers fetchStatus with queue param', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockClear() + vi.mocked(api.get).mockResolvedValue(mockFilteredStatusData) + + const store = useMqStatusStore() + store.queueType = 'orders' + await store.fetchStatus() + + expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', { queue: 'orders' }) + }) + + it('P9: refresh button triggers fetchStatus', async () => { + const { api } = await mountPage() + vi.mocked(api.get).mockClear() + vi.mocked(api.get).mockResolvedValue(mockStatusData) + + const buttons = Array.from(document.body.querySelectorAll('.ant-btn')) + const refreshBtn = buttons.find((b) => b.textContent?.trim().includes('刷新')) + expect(refreshBtn).toBeDefined() + + refreshBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + await nextTick() + + expect(api.get).toHaveBeenCalledWith('/api/v1/mq/status', {}) + }) + + it('P10: shows error alert when API fails', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.get).mockRejectedValue(new Error('RabbitMQ 连接失败')) + + const { default: MqStatusPage } = await import('../index.vue') + wrapper = mount(MqStatusPage, { + attachTo: document.body, + global: { + stubs: { ReloadOutlined: { template: '' } }, + }, + }) + await flushPromises() + await nextTick() + + const html = wrapper.html() + expect(html).toContain('RabbitMQ 连接失败') + }) + + it('P11: displays fetched_at timestamp', async () => { + await mountPage() + + const html = wrapper.html() + expect(html).toContain('2026-03-20 14:30:00') + expect(html).toContain('最后更新') + }) + + it('P12: hides error queue section when filtered', async () => { + await mountPage(mockFilteredStatusData) + + const cards = wrapper.findAll('.ant-card') + const cardTitles = cards.map((c) => c.text()) + expect(cardTitles.some((t) => t.includes('业务队列'))).toBe(true) + expect(cardTitles.some((t) => t.includes('重试队列'))).toBe(true) + // error_queue is [] when filtered, so no error queue card should render + const errorCard = cards.find((c) => c.text().includes('errors.queue')) + expect(errorCard).toBeUndefined() + }) + + it('P13: error queue messages > 0 shown with red highlight', async () => { + await mountPage() + + // error_queue has messages=3, should have red highlight class + const errorQueueCards = wrapper.findAll('.ant-card') + const errorCard = errorQueueCards.find((c) => c.text().includes('错误队列')) + expect(errorCard).toBeDefined() + + const redSpan = errorCard?.find('.text-red-500') + expect(redSpan?.exists()).toBe(true) + expect(redSpan?.text()).toBe('3') + }) +}) diff --git a/frontend/src/pages/mq-status/index.vue b/frontend/src/pages/mq-status/index.vue new file mode 100644 index 0000000..4344990 --- /dev/null +++ b/frontend/src/pages/mq-status/index.vue @@ -0,0 +1,206 @@ + + +