From 1d5321ed0c606077c7f0a3dccb0c6ca03e6222cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 3 Aug 2026 14:55:29 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20batch-create=20>100=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=20+=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BE=D0=BA=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B: BatchCreateDialog режет список на чанки ≤100 (лимит сервера), каждый чанк со своим idempotency-key (requestId-N) — повтор после ошибки не создаёт дублей; 10-минутный лок армируется только при полном успехе, а не до запроса (раньше блокировал даже после 400). D: таблица error_logs (миграция 0066), захват ответов >=400 в request middleware (без 401/health/events, дедуп 60с), admin API GET/DELETE /api/error-logs и вкладка «Журнал ошибок» в Настройках. --- .../dashboard/BatchCreateDialog.tsx | 58 ++++-- .../src/components/settings/ErrorLogsTab.tsx | 186 ++++++++++++++++++ client/src/pages/Settings.tsx | 19 +- migrations/0066_error_logs.sql | 16 ++ server/index.ts | 45 ++++- server/routes/error-logs.routes.ts | 80 ++++++++ server/routes/index.ts | 2 + shared/schema.ts | 21 ++ 8 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 client/src/components/settings/ErrorLogsTab.tsx create mode 100644 migrations/0066_error_logs.sql create mode 100644 server/routes/error-logs.routes.ts diff --git a/client/src/components/dashboard/BatchCreateDialog.tsx b/client/src/components/dashboard/BatchCreateDialog.tsx index ae4d645..2019aea 100644 --- a/client/src/components/dashboard/BatchCreateDialog.tsx +++ b/client/src/components/dashboard/BatchCreateDialog.tsx @@ -54,6 +54,8 @@ interface LockRecord { } const LOCK_DURATION_MS = 10 * 60 * 1000; +// Сервер принимает максимум 100 задач за один batch — большие пачки режем на чанки +const MAX_BATCH_SIZE = 100; function getLockKey(formId: number) { return `batch-create-lock-${formId}`; @@ -199,12 +201,6 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat const currentRequestId = requestId || crypto.randomUUID(); setRequestId(currentRequestId); - // Lock immediately so repeated clicks (or retries after a network error) cannot double-create - const lockRecord: LockRecord = { requestId: currentRequestId, startedAt: Date.now() }; - localStorage.setItem(getLockKey(formId), JSON.stringify(lockRecord)); - setIsLocked(true); - setLockMinutes(getRemainingLockMinutes(lockRecord.startedAt)); - try { setCreating(true); const taskList: Array<{ @@ -229,24 +225,48 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat taskList.push(task); } - const res = await apiRequest('POST', `/api/forms/${formId}/tasks/batch`, { - idempotencyKey: currentRequestId, - tasks: taskList, - }); - const data = await res.json(); - if (data.success) { - toast({ title: data.cached ? `Задачи уже созданы (${data.created})` : `Создано задач: ${data.created}` }); - onOpenChange(false); - onSuccess?.(); - queryClient.invalidateQueries({ queryKey: ['/api/dashboard'] }); - } else { - toast({ title: data.error || 'Ошибка создания', variant: 'destructive' }); + // Разбиваем на чанки ≤ MAX_BATCH_SIZE (лимит сервера — 100 задач за batch). + // У каждого чанка свой idempotency-key, производный от requestId: повторный запуск + // после ошибки безопасен — уже созданные чанки вернутся из серверного кэша. + const chunks: typeof taskList[] = []; + for (let i = 0; i < taskList.length; i += MAX_BATCH_SIZE) { + chunks.push(taskList.slice(i, i + MAX_BATCH_SIZE)); } + + let totalCreated = 0; + let allCached = chunks.length > 0; + for (let i = 0; i < chunks.length; i++) { + const res = await apiRequest('POST', `/api/forms/${formId}/tasks/batch`, { + idempotencyKey: `${currentRequestId}-${i}`, + tasks: chunks[i], + }); + const data = await res.json(); + if (!data.success) { + toast({ + title: chunks.length > 1 ? `Ошибка в пачке ${i + 1} из ${chunks.length}` : 'Ошибка создания', + description: data.error || undefined, + variant: 'destructive', + }); + return; // без лока — можно исправить и повторить, созданные чанки не дублируются + } + totalCreated += typeof data.created === 'number' ? data.created : chunks[i].length; + if (!data.cached) allCached = false; + } + + // Лок против повторного создания армируем только при полном успехе + const lockRecord: LockRecord = { requestId: currentRequestId, startedAt: Date.now() }; + localStorage.setItem(getLockKey(formId), JSON.stringify(lockRecord)); + setIsLocked(true); + setLockMinutes(getRemainingLockMinutes(lockRecord.startedAt)); + + toast({ title: allCached ? `Задачи уже созданы (${totalCreated})` : `Создано задач: ${totalCreated}` }); + onOpenChange(false); + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ['/api/dashboard'] }); } catch (e) { toast({ title: 'Ошибка создания задач', variant: 'destructive' }); } finally { setCreating(false); - // Lock stays active for 10 minutes regardless of success or failure } }; diff --git a/client/src/components/settings/ErrorLogsTab.tsx b/client/src/components/settings/ErrorLogsTab.tsx new file mode 100644 index 0000000..117c65a --- /dev/null +++ b/client/src/components/settings/ErrorLogsTab.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { RefreshCw, Trash2, Search } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { apiRequest, queryClient } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; + +interface ErrorLogItem { + id: number; + method: string; + path: string; + statusCode: number; + message: string | null; + createdAt: string; + userId: number | null; + userFirstName: string | null; + userLastName: string | null; + userEmail: string | null; +} + +const PAGE_SIZE = 50; + +export function ErrorLogsTab() { + const { toast } = useToast(); + const [status, setStatus] = useState('all'); + const [days, setDays] = useState('7'); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [offset, setOffset] = useState(0); + + const params = new URLSearchParams(); + params.set('limit', String(PAGE_SIZE)); + params.set('offset', String(offset)); + if (status === '4xx') params.set('status', '400'); + if (status === '5xx') params.set('status', '500'); + if (days !== 'all') params.set('days', days); + if (search) params.set('search', search); + + const { data, isFetching, refetch } = useQuery<{ success: boolean; logs: ErrorLogItem[]; total: number }>({ + queryKey: ['/api/error-logs', status, days, search, offset], + queryFn: async () => { + const res = await apiRequest('GET', `/api/error-logs?${params.toString()}`); + return res.json(); + }, + }); + + const clearMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest('DELETE', '/api/error-logs'); + return res.json(); + }, + onSuccess: () => { + toast({ title: 'Журнал ошибок очищен' }); + setOffset(0); + queryClient.invalidateQueries({ queryKey: ['/api/error-logs'] }); + }, + onError: () => toast({ title: 'Не удалось очистить журнал', variant: 'destructive' }), + }); + + const logs = data?.logs ?? []; + const total = data?.total ?? 0; + + const formatUser = (l: ErrorLogItem) => { + if (!l.userId) return '—'; + const name = `${l.userFirstName || ''} ${l.userLastName || ''}`.trim(); + return name || l.userEmail || `#${l.userId}`; + }; + + return ( +
+
+
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { setOffset(0); setSearch(searchInput.trim()); } }} + /> +
+ + + +
+ +
+ +
+ Всего записей: {total}. Одинаковые ошибки записываются не чаще раза в минуту. +
+ +
+ + + + Время + Код + Запрос + Сообщение + Пользователь + + + + {logs.length === 0 ? ( + + + {isFetching ? 'Загрузка...' : 'Ошибок нет'} + + + ) : logs.map((l) => ( + + + {new Date(l.createdAt).toLocaleString('ru-RU')} + + + = 500 ? 'destructive' : 'secondary'} className="text-[10px]"> + {l.statusCode} + + + + {l.method} {l.path} + + {l.message || '—'} + {formatUser(l)} + + ))} + +
+
+ +
+ + + {total === 0 ? 0 : offset + 1}–{Math.min(offset + PAGE_SIZE, total)} из {total} + + +
+
+ ); +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index fc2f96b..1a5b884 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useToast } from '@/hooks/use-toast'; -import { Settings as SettingsIcon, Loader2, ArrowLeft, Key, Copy, Check, Bot, BookOpen, ExternalLink, Trash2, Zap, Link2, Plus, Database, RefreshCw, BrainCircuit, Eye, EyeOff, CheckCircle2, XCircle, ServerCog, Pencil, Download, HardDrive, ChevronDown, ChevronUp, Palette } from 'lucide-react'; +import { Settings as SettingsIcon, Loader2, ArrowLeft, Key, Copy, Check, Bot, BookOpen, ExternalLink, Trash2, Zap, Link2, Plus, Database, RefreshCw, BrainCircuit, Eye, EyeOff, CheckCircle2, XCircle, ServerCog, Pencil, Download, HardDrive, ChevronDown, ChevronUp, Palette, ScrollText } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; @@ -18,6 +18,7 @@ import { Link } from 'wouter'; import { BotsContent } from '@/pages/Bots'; import { AutomationsContent } from '@/pages/Automations'; import { AppearanceSettings } from '@/pages/AppearanceSettings'; +import { ErrorLogsTab } from '@/components/settings/ErrorLogsTab'; import { Badge } from '@/components/ui/badge'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Checkbox } from '@/components/ui/checkbox'; @@ -1715,6 +1716,10 @@ const Settings = () => { API Docs + + + Журнал ошибок + @@ -1949,6 +1954,18 @@ const Settings = () => {
+ + +
+
+ + Журнал ошибок API +
+
+ +
+
+
diff --git a/migrations/0066_error_logs.sql b/migrations/0066_error_logs.sql new file mode 100644 index 0000000..df00097 --- /dev/null +++ b/migrations/0066_error_logs.sql @@ -0,0 +1,16 @@ +-- Серверный журнал ошибок API (просмотр админом в Настройках) +-- Без RLS: вставки выполняет только серверный middleware (best-effort, в т.ч. до/без auth), +-- чтение — только через admin-роут с явным фильтром organization_id. +CREATE TABLE IF NOT EXISTS error_logs ( + id SERIAL PRIMARY KEY, + organization_id INTEGER REFERENCES organizations(id) ON DELETE CASCADE, + user_id INTEGER, + method VARCHAR(10) NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS error_logs_org_created_idx ON error_logs(organization_id, created_at); +CREATE INDEX IF NOT EXISTS error_logs_status_idx ON error_logs(status_code); diff --git a/server/index.ts b/server/index.ts index 4e4e10d..e4d87ad 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,7 +18,7 @@ import { fileURLToPath } from "url"; import { isS3Enabled, ensureS3Bucket, streamFromS3 } from "./utils/s3"; import { getKnownEmbeddingDim, detectEmbeddingDimension, type LlmProviderConfig } from "./services/llm-provider"; import { decrypt as decryptSecret } from "./crypto"; -import { fileUploads } from "@shared/schema"; +import { fileUploads, errorLogs } from "@shared/schema"; import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } from "./middleware/auth.middleware"; import { EXT_TO_MIME, getFileExt } from "./utils/upload"; import crypto from "crypto"; @@ -287,12 +287,55 @@ app.use((req, res, next) => { } log(logLine); + + // Журнал ошибок API (просмотр админом в Настройках → «Журнал ошибок») + captureErrorLog(req, res.statusCode, path, capturedJsonResponse); } }); next(); }); +// --- Захват ошибок API в таблицу error_logs --- +// Шум исключаем: 401 (просроченные токены/SSE), health-check и SSE-поток. +// Дедупликация: одинаковые (method, path, status, message) не чаще раза в 60 сек. +const ERROR_LOG_EXCLUDED_PATHS = new Set(['/api/health', '/api/events']); +const errorLogDedupe = new Map(); + +function captureErrorLog(req: Request, statusCode: number, path: string, body?: Record) { + try { + if (statusCode < 400 || statusCode === 401) return; + if (ERROR_LOG_EXCLUDED_PATHS.has(path)) return; + + const rawMessage = (body && (body.error || body.message)) || ''; + const message = String(rawMessage).slice(0, 2000) || `HTTP ${statusCode}`; + + const key = `${req.method} ${path} ${statusCode} ${message.slice(0, 200)}`; + const now = Date.now(); + const lastSeen = errorLogDedupe.get(key) ?? 0; + if (now - lastSeen < 60_000) return; + errorLogDedupe.set(key, now); + // Периодическая чистка map, чтобы не разрастался + if (errorLogDedupe.size > 5000) { + for (const [k, ts] of errorLogDedupe) { + if (now - ts > 60_000) errorLogDedupe.delete(k); + } + } + + const authReq = req as AuthenticatedRequest; + db.insert(errorLogs).values({ + organizationId: authReq.organizationId ?? null, + userId: authReq.user?.id ?? null, + method: req.method, + path: path.slice(0, 500), + statusCode, + message, + }).catch((e: unknown) => console.error('[error-logs] insert failed:', e)); + } catch (e) { + console.error('[error-logs] capture failed:', e); + } +} + /** * Page seeds — canonical JS code stored in server/page-seeds/ as fallback/recovery. * Seeds are applied ONLY when the current DB code contains known critical bugs. diff --git a/server/routes/error-logs.routes.ts b/server/routes/error-logs.routes.ts new file mode 100644 index 0000000..ef47c0c --- /dev/null +++ b/server/routes/error-logs.routes.ts @@ -0,0 +1,80 @@ +import { Router } from "express"; +import { desc, eq, and, gte, or, ilike, sql } from "drizzle-orm"; +import { db } from "../db"; +import { errorLogs, users } from "@shared/schema"; +import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; + +const router = Router(); + +// Только администратор приложения +function requireAppAdmin(req: AuthenticatedRequest, res: any, next: any) { + if (req.user?.appRole !== 'admin') { + return res.status(403).json({ success: false, error: 'Доступно только администратору' }); + } + next(); +} + +// GET /api/error-logs?limit=50&offset=0&status=500&search=batch&days=7 +router.get('/api/error-logs', authenticateToken, requireAppAdmin, async (req: AuthenticatedRequest, res) => { + try { + const organizationId = req.organizationId!; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const offset = parseInt(req.query.offset as string) || 0; + const status = req.query.status ? parseInt(req.query.status as string) : undefined; + const search = (req.query.search as string || '').trim(); + const days = req.query.days ? parseInt(req.query.days as string) : undefined; + + const conditions = [eq(errorLogs.organizationId, organizationId)]; + if (status && !isNaN(status)) { + if (status === 400) conditions.push(and(gte(errorLogs.statusCode, 400), sql`${errorLogs.statusCode} < 500`)!); + else conditions.push(gte(errorLogs.statusCode, status)); + } + if (search) { + conditions.push(or(ilike(errorLogs.path, `%${search}%`), ilike(errorLogs.message, `%${search}%`))!); + } + if (days && !isNaN(days) && days > 0) { + conditions.push(gte(errorLogs.createdAt, new Date(Date.now() - days * 24 * 60 * 60 * 1000))); + } + const where = and(...conditions); + + const [rows, [{ count }]] = await Promise.all([ + db.select({ + id: errorLogs.id, + method: errorLogs.method, + path: errorLogs.path, + statusCode: errorLogs.statusCode, + message: errorLogs.message, + createdAt: errorLogs.createdAt, + userId: errorLogs.userId, + userFirstName: users.firstName, + userLastName: users.lastName, + userEmail: users.email, + }) + .from(errorLogs) + .leftJoin(users, eq(errorLogs.userId, users.id)) + .where(where) + .orderBy(desc(errorLogs.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)::int` }).from(errorLogs).where(where), + ]); + + res.json({ success: true, logs: rows, total: count, limit, offset }); + } catch (error) { + console.error('Get error logs error:', error); + res.status(500).json({ success: false, error: 'Ошибка при получении журнала ошибок' }); + } +}); + +// DELETE /api/error-logs — очистить журнал организации +router.delete('/api/error-logs', authenticateToken, requireAppAdmin, async (req: AuthenticatedRequest, res) => { + try { + await db.delete(errorLogs).where(eq(errorLogs.organizationId, req.organizationId!)); + res.json({ success: true }); + } catch (error) { + console.error('Clear error logs error:', error); + res.status(500).json({ success: false, error: 'Ошибка при очистке журнала ошибок' }); + } +}); + +export default router; diff --git a/server/routes/index.ts b/server/routes/index.ts index 537ea5f..6243aaa 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -20,6 +20,7 @@ import taskInboxHiddenRouter from "./task-inbox-hidden.routes"; import taskBulkRouter from "./task-bulk.routes"; import ragRouter from "./rag.routes"; import llmProvidersRouter from "./llm-providers.routes"; +import errorLogsRouter from "./error-logs.routes"; import { registerBotRoutes } from "./bots.routes"; import { registerExternalRoutes } from "./external.routes"; import { registerBotApiRoutes } from "./bot-api.routes"; @@ -96,6 +97,7 @@ export async function registerRoutes(app: Express): Promise { app.use(taskInboxHiddenRouter); app.use(ragRouter); app.use(llmProvidersRouter); + app.use(errorLogsRouter); // Domain route registrations registerBotRoutes(app); diff --git a/shared/schema.ts b/shared/schema.ts index 2b43ee0..5ab18c7 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -3698,6 +3698,27 @@ export const idempotencyKeys = pgTable("idempotency_keys", { export type IdempotencyKey = typeof idempotencyKeys.$inferSelect; export type InsertIdempotencyKey = typeof idempotencyKeys.$inferInsert; +// ===================== +// Error Logs (серверный журнал ошибок API, просмотр админом в Настройках) +// ===================== + +export const errorLogs = pgTable("error_logs", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").references(() => organizations.id, { onDelete: "cascade" }), + userId: integer("user_id"), + method: varchar("method", { length: 10 }).notNull(), + path: text("path").notNull(), + statusCode: integer("status_code").notNull(), + message: text("message"), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + orgCreatedIdx: index("error_logs_org_created_idx").on(table.organizationId, table.createdAt), + statusIdx: index("error_logs_status_idx").on(table.statusCode), +})); + +export type ErrorLog = typeof errorLogs.$inferSelect; +export type InsertErrorLog = typeof errorLogs.$inferInsert; + export const FINANCE_COLORS = [ "#2563eb", "#e11d48", "#16a34a", "#f59e0b", "#8b5cf6", "#06b6d4", "#ea580c", "#84cc16",