From b39a676e1a156c8b18f53f1c7943f541dce0a2ea Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Fri, 17 Jul 2026 00:44:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20auto-capture=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20source=20=D1=83?= =?UTF-8?q?=20=D1=82=D0=B8=D0=BA=D0=B5=D1=82=D0=BE=D0=B2.=20Refs=20EventHu?= =?UTF-8?q?b/EventHubBack#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/tests/ticket-auto-report.spec.ts | 48 +++++++++++ src/api/ticketsApi.ts | 15 +++- src/components/ErrorBoundary.tsx | 10 +++ src/lib/errorReporter.ts | 113 +++++++++++++++++++++++++ src/locales/en.json | 7 +- src/locales/ru.json | 7 +- src/main.tsx | 12 +++ src/pages/tickets/TicketDetailPage.tsx | 10 +++ src/pages/tickets/TicketListPage.tsx | 88 ++++++++++++++++--- src/types/api.ts | 2 + 10 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 e2e/tests/ticket-auto-report.spec.ts create mode 100644 src/lib/errorReporter.ts diff --git a/e2e/tests/ticket-auto-report.spec.ts b/e2e/tests/ticket-auto-report.spec.ts new file mode 100644 index 0000000..b3a67f6 --- /dev/null +++ b/e2e/tests/ticket-auto-report.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; +import { seedAuthenticatedSession } from '../helpers/session'; + +/** + * Авто-capture фронтовых ошибок Admin SPA → POST /v1/tickets (source=frontend). + * Ручной «сообщить о проблеме» — клиентский сценарий, не UI Control Center. + */ +test.describe('frontend error auto-report (mock)', () => { + test('reportClientError шлёт POST /v1/tickets с source=frontend', async ({ page }) => { + await seedAuthenticatedSession(page); + + let reported: { error_message?: string; source?: string } | null = null; + await page.route('**/v1/tickets', async (route) => { + if (route.request().method() === 'POST') { + reported = route.request().postDataJSON() as typeof reported; + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 't-fe-1', + source: 'frontend', + status: 'open', + count: 1, + error_hash: 'abc', + error_message: reported?.error_message ?? 'err', + }), + }); + return; + } + await route.continue(); + }); + + await page.goto('/dashboard'); + await expect(page.getByTestId('layout-shell')).toBeVisible(); + + await page.waitForFunction(() => typeof window.__ehReportClientError === 'function'); + await page.evaluate(async () => { + await window.__ehReportClientError!({ + message: 'e2e-frontend-auto-report', + stack: 'Error: e2e-frontend-auto-report\n at e2e:1:1', + source: 'frontend', + }); + }); + + await expect.poll(() => reported?.source, { timeout: 10_000 }).toBe('frontend'); + expect(reported?.error_message).toContain('e2e-frontend-auto-report'); + }); +}); diff --git a/src/api/ticketsApi.ts b/src/api/ticketsApi.ts index 75598e1..7976aac 100644 --- a/src/api/ticketsApi.ts +++ b/src/api/ticketsApi.ts @@ -1,6 +1,15 @@ import apiClient from './client'; import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api'; +export type TicketSource = 'backend' | 'frontend' | 'manual'; + +export interface CreateTicketPayload { + error_message: string; + stacktrace?: string; + context?: Record | string; + source?: TicketSource; +} + export const ticketsApi = { getTickets: async (params: TicketListParams): Promise> => { const { data, headers } = await apiClient.get('/v1/admin/tickets', { params }); @@ -11,6 +20,10 @@ export const ticketsApi = { const { data } = await apiClient.get(`/v1/admin/tickets/${id}`); return data; }, + createTicket: async (payload: CreateTicketPayload): Promise => { + const { data } = await apiClient.post('/v1/tickets', payload); + return data; + }, updateTicket: async (id: string, payload: Partial): Promise => { await apiClient.put(`/v1/admin/tickets/${id}`, payload); }, @@ -21,4 +34,4 @@ export const ticketsApi = { const { data } = await apiClient.get('/v1/admin/tickets/stats'); return data; }, -}; \ No newline at end of file +}; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 39d6dc8..a31b90d 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import React from 'react'; import i18n from '@/i18n'; +import { reportClientError } from '@/lib/errorReporter'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -13,6 +14,15 @@ export class ErrorBoundary extends React.Component { return { error }; } + componentDidCatch(error: Error, info: React.ErrorInfo) { + void reportClientError({ + message: error.message || 'React render error', + stack: error.stack || info.componentStack || undefined, + source: 'frontend', + context: { component_stack: info.componentStack || undefined }, + }); + } + render() { if (this.state.error) { return ( diff --git a/src/lib/errorReporter.ts b/src/lib/errorReporter.ts new file mode 100644 index 0000000..6c8c943 --- /dev/null +++ b/src/lib/errorReporter.ts @@ -0,0 +1,113 @@ +import { ticketsApi, type TicketSource } from '@/api/ticketsApi'; +import { ACCESS_TOKEN_KEY } from '@/utils/constants'; + +const THROTTLE_MS = 60_000; +const recentHashes = new Map(); +let reporting = false; + +function simpleHash(input: string): string { + let h = 0; + for (let i = 0; i < input.length; i++) { + h = (Math.imul(31, h) + input.charCodeAt(i)) | 0; + } + return String(h); +} + +function shouldThrottle(key: string): boolean { + const now = Date.now(); + const last = recentHashes.get(key) ?? 0; + if (now - last < THROTTLE_MS) return true; + recentHashes.set(key, now); + // prune old entries + if (recentHashes.size > 200) { + for (const [k, ts] of recentHashes) { + if (now - ts > THROTTLE_MS) recentHashes.delete(k); + } + } + return false; +} + +function buildContext(extra?: Record): Record { + return { + route: typeof window !== 'undefined' ? window.location.pathname : undefined, + user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + build: import.meta.env.VITE_APP_VERSION || '0.0', + git_sha: import.meta.env.VITE_GIT_SHA || 'dev', + ...extra, + }; +} + +export type ReportErrorInput = { + message: string; + stack?: string; + source?: TicketSource; + context?: Record; +}; + +/** + * Report an error to POST /v1/tickets. No-ops without auth token, + * while reporting is in flight for this call chain, or when throttled. + */ +export async function reportClientError(input: ReportErrorInput): Promise { + if (typeof window === 'undefined') return; + if (!localStorage.getItem(ACCESS_TOKEN_KEY)) return; + if (reporting) return; + + const source = input.source ?? (input.stack ? 'frontend' : 'manual'); + const message = (input.message || 'Unknown error').slice(0, 2000); + const stack = (input.stack || '').slice(0, 8000); + const throttleKey = simpleHash(`${source}|${message}|${stack.slice(0, 500)}`); + if (shouldThrottle(throttleKey)) return; + + reporting = true; + try { + await ticketsApi.createTicket({ + error_message: message, + stacktrace: stack || undefined, + source, + context: buildContext(input.context), + }); + } catch { + // Never rethrow — avoid error loops from the reporter itself. + } finally { + reporting = false; + } +} + +export function installGlobalErrorHandlers(): () => void { + const onError = (event: ErrorEvent) => { + const msg = event.message || 'window.onerror'; + if (msg.includes('/v1/tickets')) return; + void reportClientError({ + message: msg, + stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`, + source: 'frontend', + }); + }; + + const onRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason; + const message = + reason instanceof Error + ? reason.message + : typeof reason === 'string' + ? reason + : 'unhandledrejection'; + const stack = reason instanceof Error ? reason.stack : undefined; + if (message.includes('/v1/tickets')) return; + // Skip expected auth failures + if (typeof message === 'string' && /\b(401|403)\b/.test(message)) return; + void reportClientError({ + message, + stack, + source: 'frontend', + }); + }; + + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onRejection); + return () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onRejection); + }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 5de36a3..42f9b88 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -426,7 +426,12 @@ "detailTitle": "Ticket {{id}}", "deleteTitle": "Delete ticket?", "notFound": "Ticket not found", - "statsHint": "Explore · closed {{closed}}, total errors {{errors}}" + "statsHint": "Explore · closed {{closed}}, total errors {{errors}}", + "source": "Source", + "sourceAll": "All sources", + "sourceBackend": "Backend", + "sourceFrontend": "Frontend", + "sourceManual": "Manual" }, "subscriptions": { "title": "Subscriptions", diff --git a/src/locales/ru.json b/src/locales/ru.json index 380bef3..e9e23d3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -426,7 +426,12 @@ "detailTitle": "Тикет {{id}}", "deleteTitle": "Удалить тикет?", "notFound": "Тикет не найден", - "statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}" + "statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}", + "source": "Источник", + "sourceAll": "Все источники", + "sourceBackend": "Backend", + "sourceFrontend": "Frontend", + "sourceManual": "Ручной" }, "subscriptions": { "title": "Подписки", diff --git a/src/main.tsx b/src/main.tsx index 73345ed..66621b5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,8 +6,20 @@ import './index.css'; import './i18n'; import LocaleProvider from './components/LocaleProvider'; import { initTableDensity } from './lib/density'; +import { installGlobalErrorHandlers, reportClientError } from './lib/errorReporter'; initTableDensity(); +installGlobalErrorHandlers(); + +// E2E / debug hook for auto-report smoke (mock suite) +declare global { + interface Window { + __ehReportClientError?: typeof reportClientError; + } +} +if (typeof window !== 'undefined') { + window.__ehReportClientError = reportClientError; +} ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/tickets/TicketDetailPage.tsx b/src/pages/tickets/TicketDetailPage.tsx index e032ae5..95d37b9 100644 --- a/src/pages/tickets/TicketDetailPage.tsx +++ b/src/pages/tickets/TicketDetailPage.tsx @@ -136,6 +136,16 @@ const TicketDetailPage: React.FC = () => {
{t('common.sender')}
{getUserLink()}
+
+
{t('tickets.source')}
+
+ {ticket.source === 'frontend' + ? t('tickets.sourceFrontend') + : ticket.source === 'manual' + ? t('tickets.sourceManual') + : t('tickets.sourceBackend')} +
+
{t('common.errorHash')}
{ticket.error_hash}
diff --git a/src/pages/tickets/TicketListPage.tsx b/src/pages/tickets/TicketListPage.tsx index e25676e..eaaf1a1 100644 --- a/src/pages/tickets/TicketListPage.tsx +++ b/src/pages/tickets/TicketListPage.tsx @@ -23,9 +23,17 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; const TABLE_KEY = 'tickets'; +const SOURCE_ALL = '__all__'; const isBadValue = (val: unknown) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; @@ -37,6 +45,12 @@ const ticketStatusVariant = (status: string) => { return 'outline' as const; }; +function sourceLabel(source: string | undefined, t: TFunction): string { + if (source === 'frontend') return t('tickets.sourceFrontend'); + if (source === 'manual') return t('tickets.sourceManual'); + return t('tickets.sourceBackend'); +} + function ticketActions( handlers: { onOpen: () => void; onDelete: () => void }, t: TFunction @@ -111,6 +125,13 @@ const TicketListPage: React.FC = () => { return isBadValue(text) ? '-' : text; }, }, + { + accessorKey: 'source', + header: t('tickets.source'), + size: 100, + enableSorting: false, + cell: ({ getValue }) => sourceLabel(getValue(), t), + }, { accessorKey: 'status', header: t('common.status'), @@ -139,10 +160,13 @@ const TicketListPage: React.FC = () => { const record = row.original; return ( navigate(`/tickets/${record.id}`), - onDelete: () => handleDelete(record.id), - }, t)} + actions={ticketActions( + { + onOpen: () => navigate(`/tickets/${record.id}`), + onDelete: () => handleDelete(record.id), + }, + t + )} /> ); }, @@ -163,16 +187,22 @@ const TicketListPage: React.FC = () => { title, subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined, badges: ( - - {formatStatusLabel(record.status)} - + <> + {sourceLabel(record.source, t)} + + {formatStatusLabel(record.status)} + + ), meta: `×${record.count} · ${record.last_seen}`, onActivate: () => navigate(`/tickets/${record.id}`), - actions: ticketActions({ - onOpen: () => navigate(`/tickets/${record.id}`), - onDelete: () => handleDelete(record.id), - }, t), + actions: ticketActions( + { + onOpen: () => navigate(`/tickets/${record.id}`), + onDelete: () => handleDelete(record.id), + }, + t + ), }; }); }, [data?.data, navigate, t]); @@ -189,6 +219,32 @@ const TicketListPage: React.FC = () => { to: '/inbox/tickets', actionLabel: t('common.openInbox'), }} + toolbar={ + + } > { /> - !open && setDeleteConfirm({ open: false, ticketId: null })}> + !open && setDeleteConfirm({ open: false, ticketId: null })} + > {t('tickets.deleteTitle')}

{t('common.irreversible')}

-