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",