Добавить auto-capture ошибок и фильтр source у тикетов. Refs EventHub/EventHubBack#35
CI / test (push) Successful in 5m53s
CI / push-image (push) Successful in 10m1s
CI / ci-done (push) Successful in 0s
CI / deploy-ift (push) Successful in 47s
CI / e2e-ift (push) Successful in 2m49s
CI / deploy-stage (push) Successful in 7m58s
CI / e2e-stage (push) Successful in 1m20s
CI / test (push) Successful in 5m53s
CI / push-image (push) Successful in 10m1s
CI / ci-done (push) Successful in 0s
CI / deploy-ift (push) Successful in 47s
CI / e2e-ift (push) Successful in 2m49s
CI / deploy-stage (push) Successful in 7m58s
CI / e2e-stage (push) Successful in 1m20s
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
+14
-1
@@ -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, unknown> | string;
|
||||
source?: TicketSource;
|
||||
}
|
||||
|
||||
export const ticketsApi = {
|
||||
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
||||
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<Ticket> => {
|
||||
const { data } = await apiClient.post('/v1/tickets', payload);
|
||||
return data;
|
||||
},
|
||||
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<Props, State> {
|
||||
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 (
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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);
|
||||
};
|
||||
}
|
||||
+6
-1
@@ -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",
|
||||
|
||||
+6
-1
@@ -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": "Подписки",
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -136,6 +136,16 @@ const TicketDetailPage: React.FC = () => {
|
||||
<dt className="text-muted-foreground">{t('common.sender')}</dt>
|
||||
<dd>{getUserLink()}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('tickets.source')}</dt>
|
||||
<dd>
|
||||
{ticket.source === 'frontend'
|
||||
? t('tickets.sourceFrontend')
|
||||
: ticket.source === 'manual'
|
||||
? t('tickets.sourceManual')
|
||||
: t('tickets.sourceBackend')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
|
||||
<dd>{ticket.error_hash}</dd>
|
||||
|
||||
@@ -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<string | undefined>(), t),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('common.status'),
|
||||
@@ -139,10 +160,13 @@ const TicketListPage: React.FC = () => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<RowActionsMenu
|
||||
actions={ticketActions({
|
||||
onOpen: () => 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: (
|
||||
<Badge variant={ticketStatusVariant(record.status)}>
|
||||
{formatStatusLabel(record.status)}
|
||||
</Badge>
|
||||
<>
|
||||
<Badge variant="outline">{sourceLabel(record.source, t)}</Badge>
|
||||
<Badge variant={ticketStatusVariant(record.status)}>
|
||||
{formatStatusLabel(record.status)}
|
||||
</Badge>
|
||||
</>
|
||||
),
|
||||
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={
|
||||
<Select
|
||||
value={params.source ?? SOURCE_ALL}
|
||||
onValueChange={(value) => {
|
||||
setParams((prev) => {
|
||||
const next = { ...prev, offset: 0 };
|
||||
if (value === SOURCE_ALL) {
|
||||
delete next.source;
|
||||
} else {
|
||||
next.source = value as TicketListParams['source'];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]" data-testid="filter-ticket-source">
|
||||
<SelectValue placeholder={t('tickets.source')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SOURCE_ALL}>{t('tickets.sourceAll')}</SelectItem>
|
||||
<SelectItem value="backend">{t('tickets.sourceBackend')}</SelectItem>
|
||||
<SelectItem value="frontend">{t('tickets.sourceFrontend')}</SelectItem>
|
||||
<SelectItem value="manual">{t('tickets.sourceManual')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
>
|
||||
<ExploreDataView
|
||||
viewMode={viewMode}
|
||||
@@ -203,14 +259,20 @@ const TicketListPage: React.FC = () => {
|
||||
/>
|
||||
</ExploreListShell>
|
||||
|
||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
|
||||
<Dialog
|
||||
open={deleteConfirm.open}
|
||||
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirm({ open: false, ticketId: null })}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
|
||||
|
||||
@@ -239,10 +239,12 @@ export interface Ticket {
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
assigned_to: string | null;
|
||||
resolution_note: string | null;
|
||||
source?: 'backend' | 'frontend' | 'manual';
|
||||
}
|
||||
|
||||
export interface TicketListParams {
|
||||
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
source?: 'backend' | 'frontend' | 'manual';
|
||||
assigned_to?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
|
||||
Reference in New Issue
Block a user