feat: чанкинг batch-create >100 задач + журнал ошибок API

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 и вкладка «Журнал ошибок» в Настройках.
This commit is contained in:
2026-08-03 14:55:29 +03:00
parent 1bb6a53226
commit 1d5321ed0c
8 changed files with 406 additions and 21 deletions

View File

@@ -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
}
};

View File

@@ -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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs w-56"
placeholder="Поиск по пути или тексту..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { setOffset(0); setSearch(searchInput.trim()); } }}
/>
</div>
<Select value={status} onValueChange={(v) => { setOffset(0); setStatus(v); }}>
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Все ошибки</SelectItem>
<SelectItem value="4xx">Только 4xx</SelectItem>
<SelectItem value="5xx">Только 5xx</SelectItem>
</SelectContent>
</Select>
<Select value={days} onValueChange={(v) => { setOffset(0); setDays(v); }}>
<SelectTrigger className="h-8 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">За сутки</SelectItem>
<SelectItem value="7">За 7 дней</SelectItem>
<SelectItem value="30">За 30 дней</SelectItem>
<SelectItem value="all">За всё время</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
Обновить
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-8 text-xs gap-1 text-destructive hover:text-destructive"
disabled={clearMutation.isPending || total === 0}
onClick={() => {
if (window.confirm(`Очистить весь журнал ошибок (${total} записей)?`)) clearMutation.mutate();
}}
>
<Trash2 className="w-3.5 h-3.5" />
Очистить
</Button>
</div>
<div className="text-[11px] text-muted-foreground">
Всего записей: {total}. Одинаковые ошибки записываются не чаще раза в минуту.
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">Время</TableHead>
<TableHead className="w-[70px]">Код</TableHead>
<TableHead className="w-[220px]">Запрос</TableHead>
<TableHead>Сообщение</TableHead>
<TableHead className="w-[160px]">Пользователь</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-xs text-muted-foreground py-6">
{isFetching ? 'Загрузка...' : 'Ошибок нет'}
</TableCell>
</TableRow>
) : logs.map((l) => (
<TableRow key={l.id}>
<TableCell className="text-xs whitespace-nowrap">
{new Date(l.createdAt).toLocaleString('ru-RU')}
</TableCell>
<TableCell>
<Badge variant={l.statusCode >= 500 ? 'destructive' : 'secondary'} className="text-[10px]">
{l.statusCode}
</Badge>
</TableCell>
<TableCell className="text-xs font-mono break-all">
<span className="font-semibold">{l.method}</span> {l.path}
</TableCell>
<TableCell className="text-xs break-all">{l.message || '—'}</TableCell>
<TableCell className="text-xs">{formatUser(l)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline" size="sm" className="h-7 text-xs"
disabled={offset === 0 || isFetching}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
Назад
</Button>
<span className="text-[11px] text-muted-foreground">
{total === 0 ? 0 : offset + 1}–{Math.min(offset + PAGE_SIZE, total)} из {total}
</span>
<Button
variant="outline" size="sm" className="h-7 text-xs"
disabled={offset + PAGE_SIZE >= total || isFetching}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Вперёд
</Button>
</div>
</div>
);
}

View File

@@ -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 = () => {
<BookOpen className="w-3 h-3" />
API Docs
</TabsTrigger>
<TabsTrigger value="error-logs" className="h-6 px-2.5 text-[11px] gap-1">
<ScrollText className="w-3 h-3" />
Журнал ошибок
</TabsTrigger>
</TabsList>
<TabsContent value="bots">
@@ -1949,6 +1954,18 @@ const Settings = () => {
</div>
</div>
</TabsContent>
<TabsContent value="error-logs" className="space-y-4">
<div className="border border-border rounded">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<ScrollText className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Журнал ошибок API</span>
</div>
<div className="p-3">
<ErrorLogsTab />
</div>
</div>
</TabsContent>
</Tabs>
</div>
</main>