-
- }
- onClick={(e) => { e.stopPropagation(); openEdit(row) }}
- />
+ const isSubmitting = createObject.isPending || updateObject.isPending
+
+ const renderObjectRow = (obj: ObjectItem) => (
+
+
+ {obj.client &&
{obj.client}}
+ {obj.server_ip &&
{obj.server_ip}}
+ {obj.tags.map((t) => (
+
{t.name}
+ ))}
+ {!obj.is_active &&
Неактивен}
+ {mode === 'inventory' && (
+
+ } onClick={() => openEdit(obj)} />
handleDelete(row)}
+ onConfirm={() => handleDelete(obj)}
>
- }
- onClick={(e) => e.stopPropagation()}
- loading={deleteObject.isPending}
- />
+ } loading={deleteObject.isPending} />
- ),
- },
- ]
+ )}
+
+ )
- const isSubmitting = createObject.isPending || updateObject.isPending
+ const collapseItems = cityGroups.map(([city, objs]) => ({
+ key: city,
+ label: (
+
+ {city === UNGROUPED ? 'Без города' : city}
+ {objs.length}
+
+ ),
+ children: (
+
+ {objs.map(renderObjectRow)}
+
+ ),
+ }))
return (
@@ -186,30 +202,35 @@ export function ObjectsPage() {
}}
>
- Объекты
+ {mode === 'inventory' ? 'Инвентарь — Объекты' : 'Работа — Объекты'}
setSearchText(e.target.value)}
/>
- } onClick={openCreate}>
- Добавить
-
+ {mode === 'inventory' && (
+ } onClick={openCreate}>
+ Добавить
+
+ )}
-
+ {isLoading ? (
+ Загрузка...
+ ) : cityGroups.length === 0 ? (
+ Нет объектов
+ ) : (
+ city)}
+ items={collapseItems}
+ />
+ )}
+ {/* Create / Edit modal — only relevant in inventory mode */}
@@ -260,7 +306,7 @@ export function ObjectsPage() {
Подключение к БД объекта{' '}
- (для синхронизации устройств)
+ (для синхронизации)
diff --git a/frontend/src/pages/WorkDevices.tsx b/frontend/src/pages/WorkDevices.tsx
new file mode 100644
index 0000000..22145f9
--- /dev/null
+++ b/frontend/src/pages/WorkDevices.tsx
@@ -0,0 +1,304 @@
+import { ThunderboltOutlined } from '@ant-design/icons'
+import {
+ Button,
+ Col,
+ Form,
+ Input,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Table,
+ Tag,
+ Tooltip,
+ Typography,
+ message,
+} from 'antd'
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAllDevices, useObjectsMeta, type DeviceItem } from '../api/objects'
+import { useObjects } from '../api/objects'
+import { useActions, useCreateTask } from '../api/tasks'
+import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
+import dayjs from 'dayjs'
+
+const CATEGORY_OPTIONS = [
+ { value: 'main_server', label: 'Главный сервер' },
+ { value: 'vm', label: 'Виртуалка / сервис' },
+ { value: 'router', label: 'Роутер / шлюз' },
+ { value: 'embedded', label: 'Пром-ПК' },
+ { value: 'camera', label: 'Камера' },
+ { value: 'io_board', label: 'Модуль ввода/вывода' },
+ { value: 'bank_terminal', label: 'Банковский терминал' },
+ { value: 'cash_register', label: 'ККТ' },
+ { value: 'other', label: 'Другое' },
+]
+
+export function WorkDevicesPage() {
+ const navigate = useNavigate()
+ const { data: meta } = useObjectsMeta()
+ const { data: allObjects } = useObjects()
+ const { data: actions } = useActions()
+ const createTask = useCreateTask()
+
+ const [filterCategory, setFilterCategory] = useState([])
+ const [filterCity, setFilterCity] = useState()
+ const [filterClient, setFilterClient] = useState()
+ const [filterObjectId, setFilterObjectId] = useState()
+ const [filterStatus, setFilterStatus] = useState()
+ const [selectedRowKeys, setSelectedRowKeys] = useState([])
+
+ const [taskModal, setTaskModal] = useState(false)
+ const [selectedAction, setSelectedAction] = useState('')
+ const [form] = Form.useForm()
+
+ const { data: devices, isLoading } = useAllDevices({
+ category: filterCategory.length > 0 ? filterCategory : undefined,
+ city: filterCity,
+ client: filterClient,
+ object_id: filterObjectId,
+ status: filterStatus,
+ })
+
+ const currentAction = actions?.find((a) => a.name === selectedAction)
+
+ const openTaskModal = () => {
+ setTaskModal(true)
+ setSelectedAction('')
+ form.resetFields()
+ }
+
+ const submitTask = async () => {
+ const values = await form.validateFields()
+ const { action, ...params } = values
+
+ // If specific devices selected — run on each; else run with current filters as scope
+ const scope =
+ selectedRowKeys.length > 0
+ ? { type: 'device_list', ids: selectedRowKeys }
+ : {
+ type: 'filter',
+ category: filterCategory.length > 0 ? filterCategory : undefined,
+ city: filterCity,
+ client: filterClient,
+ object_id: filterObjectId,
+ }
+
+ try {
+ const task = await createTask.mutateAsync({ type: action, target_scope: scope, params })
+ message.success('Задача создана')
+ setTaskModal(false)
+ navigate(`/tasks/${task.id}`)
+ } catch {
+ message.error('Ошибка создания задачи')
+ }
+ }
+
+ const objectOptions = (allObjects ?? []).map((o) => ({
+ value: o.id,
+ label: o.city ? `${o.city} — ${o.name}` : o.name,
+ }))
+
+ const columns = [
+ {
+ title: 'IP',
+ dataIndex: 'ip',
+ key: 'ip',
+ render: (ip: string) => {ip},
+ },
+ {
+ title: 'Hostname',
+ dataIndex: 'hostname',
+ key: 'hostname',
+ render: (h: string | null) => h ?? '—',
+ },
+ {
+ title: 'Категория',
+ dataIndex: 'category',
+ key: 'category',
+ render: (c: string) => ,
+ },
+ {
+ title: 'Объект',
+ dataIndex: 'object_id',
+ key: 'object_id',
+ render: (objId: number) => {
+ const obj = (allObjects ?? []).find((o) => o.id === objId)
+ if (!obj) return objId
+ return (
+
+ {obj.city && {obj.city}}
+
+
+ )
+ },
+ },
+ {
+ title: 'Клиент',
+ key: 'client',
+ render: (_: unknown, row: DeviceItem) => {
+ const obj = (allObjects ?? []).find((o) => o.id === row.object_id)
+ return obj?.client ? {obj.client} : —
+ },
+ },
+ {
+ title: 'Статус',
+ dataIndex: 'status',
+ key: 'status',
+ render: (s: string) => ,
+ },
+ {
+ title: 'Последний ping',
+ dataIndex: 'last_seen',
+ key: 'last_seen',
+ render: (ts: string | null, row: DeviceItem) =>
+ ts ? (
+
+ {row.last_ping_ms}ms
+
+ ) : '—',
+ },
+ ]
+
+ return (
+
+
+
+ Все устройства
+
+ }
+ onClick={openTaskModal}
+ disabled={!devices || devices.length === 0}
+ >
+ {selectedRowKeys.length > 0
+ ? `Задача на выбранные (${selectedRowKeys.length})`
+ : 'Задача на выборку'}
+
+
+
+ {/* Filters */}
+
+
+
+
+
+
+
+ {devices && (
+
+ Найдено: {devices.length} устройств
+
+ )}
+
+
setSelectedRowKeys(keys as number[]),
+ }}
+ />
+
+ {/* Task Modal */}
+ setTaskModal(false)}
+ onOk={submitTask}
+ confirmLoading={createTask.isPending}
+ okText="Запустить"
+ >
+ {selectedRowKeys.length > 0 ? (
+
+ Выбрано устройств: {selectedRowKeys.length}
+
+ ) : (
+
+ Задача будет запущена на все устройства из текущей выборки ({devices?.length ?? 0} шт.)
+
+ )}
+
+ ({ value: a.name, label: a.display_name }))}
+ onChange={setSelectedAction}
+ />
+
+ {currentAction?.params_schema.map((p) => (
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/WorkObjectDetail.tsx b/frontend/src/pages/WorkObjectDetail.tsx
new file mode 100644
index 0000000..b0bca01
--- /dev/null
+++ b/frontend/src/pages/WorkObjectDetail.tsx
@@ -0,0 +1,254 @@
+import {
+ PlayCircleOutlined,
+ ThunderboltOutlined,
+} from '@ant-design/icons'
+import {
+ Breadcrumb,
+ Button,
+ Col,
+ Descriptions,
+ Form,
+ Input,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Spin,
+ Table,
+ Tag,
+ Tooltip,
+ Typography,
+ message,
+} from 'antd'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useDevices, useObject, type DeviceItem } from '../api/objects'
+import { useRacks } from '../api/racks'
+import { useActions, useCreateTask } from '../api/tasks'
+import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
+import dayjs from 'dayjs'
+
+export function WorkObjectDetailPage() {
+ const { id } = useParams<{ id: string }>()
+ const objId = Number(id)
+ const navigate = useNavigate()
+
+ const { data: obj, isLoading: objLoading } = useObject(objId)
+ const { data: devices, isLoading: devLoading } = useDevices(objId)
+ const { data: racks } = useRacks(objId)
+ const { data: actions } = useActions()
+ const createTask = useCreateTask()
+
+ const [taskModal, setTaskModal] = useState<{ scope: object; title: string } | null>(null)
+ const [selectedAction, setSelectedAction] = useState('')
+ const [searchText, setSearchText] = useState('')
+ const [form] = Form.useForm()
+
+ const currentAction = actions?.find((a) => a.name === selectedAction)
+
+ const openTaskModal = (scope: object, title: string) => {
+ setTaskModal({ scope, title })
+ setSelectedAction('')
+ form.resetFields()
+ }
+
+ const submitTask = async () => {
+ const values = await form.validateFields()
+ const { action, ...params } = values
+ try {
+ const task = await createTask.mutateAsync({
+ type: action,
+ target_scope: taskModal!.scope,
+ params,
+ })
+ message.success('Задача создана')
+ setTaskModal(null)
+ navigate(`/tasks/${task.id}`)
+ } catch {
+ message.error('Ошибка создания задачи')
+ }
+ }
+
+ const filteredDevices = useMemo(() => {
+ if (!searchText) return devices ?? []
+ const q = searchText.toLowerCase()
+ return (devices ?? []).filter(
+ (d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
+ )
+ }, [devices, searchText])
+
+ if (objLoading) return
+
+ const deviceColumns = [
+ {
+ title: 'IP',
+ dataIndex: 'ip',
+ key: 'ip',
+ render: (ip: string) => {ip},
+ },
+ {
+ title: 'Hostname',
+ dataIndex: 'hostname',
+ key: 'hostname',
+ render: (h: string | null) => h ?? '—',
+ },
+ {
+ title: 'Стойка',
+ dataIndex: 'rack_name',
+ key: 'rack_name',
+ render: (name: string | null) =>
+ name ? {name} : —,
+ filters: [
+ { text: 'Без стойки', value: '__none__' },
+ ...(racks ?? []).map((r) => ({ text: r.name, value: r.name })),
+ ],
+ onFilter: (value: unknown, record: DeviceItem) => {
+ if (value === '__none__') return record.rack_name === null
+ return record.rack_name === value
+ },
+ },
+ {
+ title: 'Категория',
+ dataIndex: 'category',
+ key: 'category',
+ render: (c: string) => ,
+ filters: [
+ { text: 'Главный сервер', value: 'main_server' },
+ { text: 'Виртуалка', value: 'vm' },
+ { text: 'Роутер', value: 'router' },
+ { text: 'Пром-ПК', value: 'embedded' },
+ { text: 'Камера', value: 'camera' },
+ { text: 'Модуль В/В', value: 'io_board' },
+ { text: 'Банк. терминал', value: 'bank_terminal' },
+ { text: 'ККТ', value: 'cash_register' },
+ { text: 'Другое', value: 'other' },
+ ],
+ onFilter: (value: unknown, record: DeviceItem) => record.category === value,
+ },
+ {
+ title: 'Статус',
+ dataIndex: 'status',
+ key: 'status',
+ render: (s: string) => ,
+ },
+ {
+ title: 'Последний ping',
+ dataIndex: 'last_seen',
+ key: 'last_seen',
+ render: (ts: string | null, row: DeviceItem) =>
+ ts ? (
+
+ {row.last_ping_ms}ms
+
+ ) : '—',
+ },
+ {
+ title: '',
+ key: 'actions',
+ width: 110,
+ render: (_: unknown, row: DeviceItem) => (
+ }
+ onClick={() => openTaskModal({ type: 'device', id: row.id }, `Задача: ${row.hostname ?? row.ip}`)}
+ >
+ Задача
+
+ ),
+ },
+ ]
+
+ // Breadcrumb: Работа → [City →] Object
+ const breadcrumbItems = [
+ { title: navigate('/work/objects')}>Работа },
+ ...(obj?.city ? [{ title: navigate('/work/objects')}>{obj.city} }] : []),
+ { title: obj?.name },
+ ]
+
+ return (
+
+
+
+
+
+
+ {obj?.server_ip ?? '—'}
+ {obj?.client ?? '—'}
+ {obj?.ssh_user ?? '—'}
+
+
+
+
+ }
+ onClick={() => openTaskModal({ type: 'object', id: objId }, 'Задача на весь объект')}
+ >
+ Задача на объект
+
+
+
+
+
+
+
+
setSearchText(e.target.value)}
+ />
+
+
+
+ {/* Task Modal */}
+ setTaskModal(null)}
+ onOk={submitTask}
+ confirmLoading={createTask.isPending}
+ okText="Запустить"
+ >
+
+ ({ value: a.name, label: a.display_name }))}
+ onChange={setSelectedAction}
+ />
+
+ {currentAction?.params_schema.map((p) => (
+
+
+
+ ))}
+
+
+
+ )
+}