этап 2 ок?

This commit is contained in:
dv 2026-04-07 01:05:59 +03:00
parent 68b9fa7417
commit 86c02eb2e9
7 changed files with 468 additions and 73 deletions

View file

@ -87,7 +87,7 @@ async def create_device(
return device
@router.get("/import/preview", response_model=DeviceImportPreview)
@router.post("/import/preview", response_model=DeviceImportPreview)
async def preview_csv_import(
obj_id: int,
file: Annotated[UploadFile, File()],

View file

@ -7,12 +7,11 @@ Expected table schema (racks):
name TEXT short label, e.g. "01"
type TEXT "rack.lane" | "rack.payment" | ...
data JSONB contains "host" (IP address) and other device-specific fields
SSH tunnel support is deferred to Этап 2.
"""
from dataclasses import dataclass, field
import asyncssh
import psycopg
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -51,6 +50,15 @@ def _map_category(raw: str | None) -> str:
return _CATEGORY_MAP.get(raw.strip().lower(), "other")
async def _fetch_rows(dsn: str, table: str) -> list[tuple]:
"""Open a short-lived psycopg connection and fetch device rows."""
async with await psycopg.AsyncConnection.connect(dsn) as conn:
cursor = await conn.execute(
f"SELECT id, name, type, data->>'host' AS host FROM \"{table}\""
)
return await cursor.fetchall()
async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
result = SyncResult()
@ -58,28 +66,53 @@ async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
result.errors.append("Object DB connection is not fully configured")
return result
if obj.db_via_tunnel:
result.errors.append("SSH tunnel sync is not yet supported (coming in Этап 2)")
return result
try:
password = decrypt(obj.db_pass_enc)
db_password = decrypt(obj.db_pass_enc)
except Exception:
result.errors.append("Failed to decrypt object DB password")
return result
dsn = (
f"host={obj.db_host} port={obj.db_port} dbname={obj.db_name} "
f"user={obj.db_user} password={password} connect_timeout=10"
)
if obj.db_via_tunnel:
# Connect via SSH tunnel through obj.server_ip
if not obj.server_ip or not obj.ssh_user or not obj.ssh_pass_enc:
result.errors.append("SSH credentials not configured for tunnel (need server_ip, ssh_user, ssh_password)")
return result
try:
ssh_password = decrypt(obj.ssh_pass_enc)
except Exception:
result.errors.append("Failed to decrypt SSH password for tunnel")
return result
try:
async with await psycopg.AsyncConnection.connect(dsn) as conn:
# Pull id, name, type and host from the JSONB data column
rows = await conn.execute(
f'SELECT id, name, type, data->>\'host\' AS host FROM "{obj.db_table}"'
async with asyncssh.connect(
obj.server_ip,
port=obj.ssh_port or 22,
username=obj.ssh_user,
password=ssh_password,
known_hosts=None,
) as tunnel:
listener = await tunnel.forward_local_port(
"127.0.0.1", 0, obj.db_host, obj.db_port or 5432
)
remote_rows = await rows.fetchall()
local_port = listener.get_port()
dsn = (
f"host=127.0.0.1 port={local_port} dbname={obj.db_name} "
f"user={obj.db_user} password={db_password} connect_timeout=10"
)
try:
remote_rows = await _fetch_rows(dsn, obj.db_table)
finally:
listener.close()
except Exception as exc:
result.errors.append(f"SSH tunnel failed: {exc}")
return result
else:
dsn = (
f"host={obj.db_host} port={obj.db_port or 5432} dbname={obj.db_name} "
f"user={obj.db_user} password={db_password} connect_timeout=10"
)
try:
remote_rows = await _fetch_rows(dsn, obj.db_table)
except Exception as exc:
result.errors.append(f"Cannot connect to object DB: {exc}")
return result

View file

@ -38,6 +38,18 @@ export interface SyncResult {
errors: string[]
}
export interface DeviceImportPreview {
would_create: { ip: string; hostname?: string; category: string; role: string; model?: string }[]
would_update: { ip: string; id: number; changes: Record<string, { from: unknown; to: unknown }> }[]
errors: { row: number; error: string }[]
}
export interface DeviceImportResult {
created: number
updated: number
errors: { row: number; error: string }[]
}
export const useObjects = (isActive?: boolean) =>
useQuery({
queryKey: ['objects', isActive],
@ -88,6 +100,23 @@ export const useCreateObject = () => {
})
}
export const useUpdateObject = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Partial<ObjectCreatePayload> }) =>
api.patch<ObjectItem>(`/api/v1/objects/${id}`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
})
}
export const useDeleteObject = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/objects/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
})
}
export interface DeviceCreatePayload {
ip: string
hostname?: string
@ -110,6 +139,39 @@ export const useCreateDevice = (objId: number) => {
})
}
export const useDeleteDevice = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (devId: number) => api.delete(`/api/v1/objects/${objId}/devices/${devId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}
export const usePreviewCSV = (objId: number) =>
useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api
.post<DeviceImportPreview>(`/api/v1/objects/${objId}/devices/import/preview`, form)
.then((r) => r.data)
},
})
export const useImportCSV = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api
.post<DeviceImportResult>(`/api/v1/objects/${objId}/devices/import`, form)
.then((r) => r.data)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}
export const useSyncObject = (objId: number) => {
const qc = useQueryClient()
return useMutation({

View file

@ -45,10 +45,13 @@ export interface ActionInfo {
params_schema: { name: string; type: string; label: string; required?: boolean }[]
}
export const useTasks = () =>
export const useTasks = (page = 1, pageSize = 50) =>
useQuery({
queryKey: ['tasks'],
queryFn: () => api.get<Task[]>('/api/v1/tasks').then((r) => r.data),
queryKey: ['tasks', page, pageSize],
queryFn: () =>
api
.get<Task[]>('/api/v1/tasks', { params: { skip: (page - 1) * pageSize, limit: pageSize } })
.then((r) => r.data),
refetchInterval: 5000,
})

View file

@ -1,10 +1,13 @@
import {
DeleteOutlined,
PlayCircleOutlined,
PlusOutlined,
SyncOutlined,
ThunderboltOutlined,
UploadOutlined,
} from '@ant-design/icons'
import {
Alert,
Breadcrumb,
Button,
Col,
@ -14,6 +17,7 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
@ -21,11 +25,24 @@ import {
Table,
Tag,
Tooltip,
Typography,
Upload,
message,
} from 'antd'
import type { UploadFile } from 'antd'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useCreateDevice, useDevices, useObject, useSyncObject, type DeviceItem } from '../api/objects'
import {
useCreateDevice,
useDeleteDevice,
useDevices,
useImportCSV,
useObject,
usePreviewCSV,
useSyncObject,
type DeviceImportPreview,
type DeviceItem,
} from '../api/objects'
import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs'
@ -40,11 +57,18 @@ export function ObjectDetailPage() {
const syncMutation = useSyncObject(objId)
const { data: actions } = useActions()
const createTask = useCreateTask()
const createDevice = useCreateDevice(objId)
const deleteDevice = useDeleteDevice(objId)
const previewCSV = usePreviewCSV(objId)
const importCSV = useImportCSV(objId)
const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null)
const [deviceModal, setDeviceModal] = useState(false)
const [csvModal, setCsvModal] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const [deviceForm] = Form.useForm()
const [selectedAction, setSelectedAction] = useState<string>('')
@ -66,7 +90,7 @@ export function ObjectDetailPage() {
target_scope: taskModal!.scope,
params,
})
message.success(`Задача создана`)
message.success('Задача создана')
setTaskModal(null)
navigate(`/tasks/${task.id}`)
} catch {
@ -98,6 +122,50 @@ export function ObjectDetailPage() {
}
}
const handleDeleteDevice = async (row: DeviceItem) => {
try {
await deleteDevice.mutateAsync(row.id)
message.success(`Устройство ${row.ip} удалено`)
} catch {
message.error('Ошибка при удалении устройства')
}
}
const handleCsvPreview = async () => {
if (!csvFile) return
try {
const preview = await previewCSV.mutateAsync(csvFile)
setCsvPreview(preview)
} catch {
message.error('Не удалось разобрать CSV')
}
}
const handleCsvImport = async () => {
if (!csvFile) return
try {
const result = await importCSV.mutateAsync(csvFile)
message.success(`Импортировано: +${result.created} создано, ${result.updated} обновлено`)
setCsvModal(false)
setCsvFile(null)
setCsvPreview(null)
} catch {
message.error('Ошибка при импорте CSV')
}
}
const closeCsvModal = () => {
setCsvModal(false)
setCsvFile(null)
setCsvPreview(null)
}
const filteredDevices = (devices ?? []).filter((d) => {
if (!searchText) return true
const q = searchText.toLowerCase()
return d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
})
if (objLoading) return <Spin />
const columns = [
@ -118,7 +186,10 @@ export function ObjectDetailPage() {
dataIndex: 'category',
key: 'category',
render: (c: string) => <CategoryTag category={c} />,
filters: ['raspberry', 'mikrotik', 'camera', 'embedded', 'server', 'other'].map((v) => ({ text: v, value: v })),
filters: ['raspberry', 'mikrotik', 'camera', 'embedded', 'server', 'other'].map((v) => ({
text: v,
value: v,
})),
onFilter: (value: unknown, record: DeviceItem) => record.category === value,
},
{
@ -149,7 +220,9 @@ export function ObjectDetailPage() {
{
title: '',
key: 'actions',
width: 120,
render: (_: unknown, row: DeviceItem) => (
<Space>
<Button
size="small"
icon={<PlayCircleOutlined />}
@ -157,10 +230,40 @@ export function ObjectDetailPage() {
>
Запустить
</Button>
<Popconfirm
title="Удалить устройство?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteDevice(row)}
>
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
</Popconfirm>
</Space>
),
},
]
// CSV preview columns
const previewCreateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string) => v ?? '—' },
{ title: 'Категория', dataIndex: 'category', key: 'category' },
{ title: 'Роль', dataIndex: 'role', key: 'role' },
]
const previewUpdateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
{
title: 'Изменения',
dataIndex: 'changes',
key: 'changes',
render: (changes: Record<string, { from: unknown; to: unknown }>) =>
Object.entries(changes)
.map(([k, v]) => `${k}: ${v.from}${v.to}`)
.join(', ') || '—',
},
]
return (
<div>
<Breadcrumb
@ -181,17 +284,13 @@ export function ObjectDetailPage() {
</Col>
<Col>
<Space>
<Button
icon={<SyncOutlined />}
loading={syncMutation.isPending}
onClick={handleSync}
>
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
Синхронизировать из БД
</Button>
<Button
icon={<PlusOutlined />}
onClick={() => setDeviceModal(true)}
>
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
Импорт CSV
</Button>
<Button icon={<PlusOutlined />} onClick={() => setDeviceModal(true)}>
Добавить устройство
</Button>
<Button
@ -205,8 +304,15 @@ export function ObjectDetailPage() {
</Col>
</Row>
<Input.Search
placeholder="Поиск по IP или hostname..."
allowClear
style={{ marginBottom: 12, maxWidth: 360 }}
onChange={(e) => setSearchText(e.target.value)}
/>
<Table
dataSource={devices ?? []}
dataSource={filteredDevices}
columns={columns}
rowKey="id"
loading={devLoading}
@ -214,6 +320,95 @@ export function ObjectDetailPage() {
pagination={{ pageSize: 50 }}
/>
{/* ── CSV Import Modal ── */}
<Modal
title="Импорт устройств из CSV"
open={csvModal}
onCancel={closeCsvModal}
width={700}
footer={
<Space>
<Button onClick={closeCsvModal}>Отмена</Button>
{!csvPreview && (
<Button
onClick={handleCsvPreview}
loading={previewCSV.isPending}
disabled={!csvFile}
>
Предпросмотр
</Button>
)}
{csvPreview && (
<Button type="primary" onClick={handleCsvImport} loading={importCSV.isPending}>
Импортировать
</Button>
)}
</Space>
}
>
{!csvPreview ? (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
Формат CSV: <code>ip, hostname, category, role, model, mac, notes</code>
</Typography.Paragraph>
<Upload.Dragger
accept=".csv"
maxCount={1}
beforeUpload={(file) => { setCsvFile(file); return false }}
onRemove={() => setCsvFile(null)}
fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []}
>
<p className="ant-upload-drag-icon">
<UploadOutlined style={{ fontSize: 32, color: '#1677ff' }} />
</p>
<p className="ant-upload-text">Перетащите CSV-файл или нажмите для выбора</p>
</Upload.Dragger>
</>
) : (
<>
{csvPreview.errors.length > 0 && (
<Alert
type="warning"
style={{ marginBottom: 12 }}
message={`${csvPreview.errors.length} строк с ошибками`}
description={csvPreview.errors.map((e) => `Строка ${e.row}: ${e.error}`).join('\n')}
/>
)}
{csvPreview.would_create.length > 0 && (
<>
<Typography.Text strong>
Будет создано: {csvPreview.would_create.length}
</Typography.Text>
<Table
size="small"
dataSource={csvPreview.would_create}
columns={previewCreateCols}
rowKey="ip"
pagination={false}
style={{ marginTop: 8, marginBottom: 16 }}
/>
</>
)}
{csvPreview.would_update.length > 0 && (
<>
<Typography.Text strong>
Будет обновлено: {csvPreview.would_update.length}
</Typography.Text>
<Table
size="small"
dataSource={csvPreview.would_update}
columns={previewUpdateCols}
rowKey="ip"
pagination={false}
style={{ marginTop: 8 }}
/>
</>
)}
</>
)}
</Modal>
{/* ── Add Device Modal ── */}
<Modal
title="Добавить устройство"
open={deviceModal}
@ -226,7 +421,11 @@ export function ObjectDetailPage() {
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Form.Item
name="ip"
label="IP-адрес"
rules={[{ required: true, message: 'Обязательное поле' }]}
>
<Input placeholder="192.168.1.100" />
</Form.Item>
</Col>
@ -254,7 +453,11 @@ export function ObjectDetailPage() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="role" label="Роль" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Form.Item
name="role"
label="Роль"
rules={[{ required: true, message: 'Обязательное поле' }]}
>
<Input placeholder="entry / parking / barrier" />
</Form.Item>
</Col>
@ -273,7 +476,9 @@ export function ObjectDetailPage() {
</Col>
</Row>
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
<Divider orientation="left" plain>
SSH (переопределить настройки объекта)
</Divider>
<Row gutter={16}>
<Col span={8}>
@ -295,6 +500,7 @@ export function ObjectDetailPage() {
</Form>
</Modal>
{/* ── Task Modal ── */}
<Modal
title="Создать задачу"
open={!!taskModal}

View file

@ -1,4 +1,4 @@
import { PlusOutlined } from '@ant-design/icons'
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import {
Button,
Checkbox,
@ -8,38 +8,96 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useCreateObject, useObjects, type ObjectItem } from '../api/objects'
import {
useCreateObject,
useDeleteObject,
useUpdateObject,
useObjects,
type ObjectItem,
} from '../api/objects'
type ModalMode = 'create' | 'edit'
export function ObjectsPage() {
const navigate = useNavigate()
const { data: objects, isLoading } = useObjects()
const createObject = useCreateObject()
const updateObject = useUpdateObject()
const deleteObject = useDeleteObject()
const [modalOpen, setModalOpen] = useState(false)
const [modalMode, setModalMode] = useState<ModalMode>('create')
const [editTarget, setEditTarget] = useState<ObjectItem | null>(null)
const [form] = Form.useForm()
const handleCreate = async () => {
useEffect(() => {
if (modalMode === 'edit' && editTarget) {
form.setFieldsValue({
name: editTarget.name,
description: editTarget.description ?? '',
server_ip: editTarget.server_ip ?? '',
ssh_user: editTarget.ssh_user ?? '',
db_host: editTarget.db_host ?? '',
// passwords intentionally left blank — only sent if filled
})
}
}, [editTarget, modalMode, form])
const openCreate = () => {
setModalMode('create')
setEditTarget(null)
form.resetFields()
setModalOpen(true)
}
const openEdit = (row: ObjectItem) => {
setModalMode('edit')
setEditTarget(row)
form.resetFields()
setModalOpen(true)
}
const handleSubmit = async () => {
const values = await form.validateFields()
// strip empty strings to undefined
const payload = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
)
try {
if (modalMode === 'create') {
await createObject.mutateAsync(payload as any)
message.success('Объект создан')
} else {
await updateObject.mutateAsync({ id: editTarget!.id, body: payload as any })
message.success('Объект обновлён')
}
setModalOpen(false)
form.resetFields()
} catch {
message.error('Ошибка при создании объекта')
message.error(modalMode === 'create' ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта')
}
}
const handleDelete = async (row: ObjectItem) => {
try {
await deleteObject.mutateAsync(row.id)
message.success(`Объект "${row.name}" удалён`)
} catch (err: any) {
const detail = err?.response?.data?.detail
if (err?.response?.status === 409) {
message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства')
} else {
message.error(detail ?? 'Ошибка при удалении объекта')
}
}
}
@ -89,16 +147,40 @@ export function ObjectsPage() {
{
title: '',
key: 'actions',
width: 160,
render: (_: unknown, row: ObjectItem) => (
<Space>
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
Устройства
</Button>
<Button
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); openEdit(row) }}
/>
<Popconfirm
title="Удалить объект?"
description="Объект можно удалить только если у него нет устройств."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row)}
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
loading={deleteObject.isPending}
/>
</Popconfirm>
</Space>
),
},
]
const isSubmitting = createObject.isPending || updateObject.isPending
return (
<div>
<div
@ -112,7 +194,7 @@ export function ObjectsPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
Объекты
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Добавить
</Button>
</div>
@ -127,16 +209,15 @@ export function ObjectsPage() {
/>
<Modal
title="Новый объект"
title={modalMode === 'create' ? 'Новый объект' : `Редактировать: ${editTarget?.name}`}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleCreate}
confirmLoading={createObject.isPending}
okText="Создать"
onOk={handleSubmit}
confirmLoading={isSubmitting}
okText={modalMode === 'create' ? 'Создать' : 'Сохранить'}
width={640}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
{/* ── Основное ── */}
<Row gutter={16}>
<Col span={14}>
<Form.Item name="name" label="Название" rules={[{ required: true, message: 'Обязательное поле' }]}>
@ -163,8 +244,8 @@ export function ObjectsPage() {
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password" label="Пароль">
<Input.Password placeholder="••••••••" />
<Form.Item name="ssh_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item>
</Col>
<Col span={6}>
@ -206,8 +287,8 @@ export function ObjectsPage() {
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_password" label="Пароль">
<Input.Password placeholder="••••••••" />
<Form.Item name="db_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item>
</Col>
</Row>
@ -215,7 +296,7 @@ export function ObjectsPage() {
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_table" label="Таблица">
<Input placeholder="devices" />
<Input placeholder="racks" />
</Form.Item>
</Col>
<Col span={8}>

View file

@ -1,12 +1,16 @@
import { Table, Typography } from 'antd'
import dayjs from 'dayjs'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTasks, type Task } from '../api/tasks'
import { TaskStatusBadge } from '../components/StatusBadge'
const PAGE_SIZE = 50
export function TasksPage() {
const navigate = useNavigate()
const { data: tasks, isLoading } = useTasks()
const [page, setPage] = useState(1)
const { data: tasks, isLoading } = useTasks(page, PAGE_SIZE)
const columns = [
{
@ -38,8 +42,7 @@ export function TasksPage() {
title: 'Итог',
dataIndex: 'result',
key: 'result',
render: (r: Task['result']) =>
r ? `${r.ok}/${r.total}` : '—',
render: (r: Task['result']) => (r ? `${r.ok}/${r.total}` : '—'),
},
{
title: 'Создана',
@ -60,7 +63,14 @@ export function TasksPage() {
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 30 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
onChange: setPage,
// Backend doesn't return total count — hide size changer, show simple pagination
showSizeChanger: false,
simple: true,
}}
onRow={(row) => ({ onClick: () => navigate(`/tasks/${row.id}`) })}
rowClassName={() => 'cursor-pointer'}
/>