diff --git a/frontend/src/pages/WorkDevices.tsx b/frontend/src/pages/WorkDevices.tsx
index 22145f9..5468482 100644
--- a/frontend/src/pages/WorkDevices.tsx
+++ b/frontend/src/pages/WorkDevices.tsx
@@ -14,10 +14,9 @@ import {
Typography,
message,
} from 'antd'
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import { useAllDevices, useObjectsMeta, type DeviceItem } from '../api/objects'
-import { useObjects } from '../api/objects'
+import { useAllDevices, useObjectsMeta, useObjects, type DeviceItem } from '../api/objects'
import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs'
@@ -34,6 +33,13 @@ const CATEGORY_OPTIONS = [
{ value: 'other', label: 'Другое' },
]
+const STATUS_OPTIONS = [
+ { value: 'online', label: 'Online' },
+ { value: 'offline', label: 'Offline' },
+ { value: 'unknown', label: 'Unknown' },
+ { value: 'degraded', label: 'Degraded' },
+]
+
export function WorkDevicesPage() {
const navigate = useNavigate()
const { data: meta } = useObjectsMeta()
@@ -62,6 +68,38 @@ export function WorkDevicesPage() {
const currentAction = actions?.find((a) => a.name === selectedAction)
+ // Object dropdown filtered by active city/client selectors
+ const objectOptions = useMemo(() => {
+ return (allObjects ?? [])
+ .filter((o) => {
+ if (filterCity && o.city !== filterCity) return false
+ if (filterClient && o.client !== filterClient) return false
+ return true
+ })
+ .map((o) => ({
+ value: o.id,
+ label: o.city ? `${o.city} — ${o.name}` : o.name,
+ }))
+ }, [allObjects, filterCity, filterClient])
+
+ const handleCityChange = (val: string | undefined) => {
+ setFilterCity(val)
+ // Reset object if it no longer matches new city
+ if (filterObjectId !== undefined) {
+ const obj = (allObjects ?? []).find((o) => o.id === filterObjectId)
+ if (obj && val && obj.city !== val) setFilterObjectId(undefined)
+ }
+ }
+
+ const handleClientChange = (val: string | undefined) => {
+ setFilterClient(val)
+ // Reset object if it no longer matches new client
+ if (filterObjectId !== undefined) {
+ const obj = (allObjects ?? []).find((o) => o.id === filterObjectId)
+ if (obj && val && obj.client !== val) setFilterObjectId(undefined)
+ }
+ }
+
const openTaskModal = () => {
setTaskModal(true)
setSelectedAction('')
@@ -72,7 +110,6 @@ export function WorkDevicesPage() {
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 }
@@ -94,11 +131,6 @@ export function WorkDevicesPage() {
}
}
- const objectOptions = (allObjects ?? []).map((o) => ({
- value: o.id,
- label: o.city ? `${o.city} — ${o.name}` : o.name,
- }))
-
const columns = [
{
title: 'IP',
@@ -206,7 +238,7 @@ export function WorkDevicesPage() {
allowClear
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
value={filterCity}
- onChange={setFilterCity}
+ onChange={handleCityChange}
/>
@@ -216,7 +248,7 @@ export function WorkDevicesPage() {
allowClear
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
value={filterClient}
- onChange={setFilterClient}
+ onChange={handleClientChange}
/>
@@ -236,11 +268,7 @@ export function WorkDevicesPage() {
placeholder="Статус"
style={{ width: '100%' }}
allowClear
- options={[
- { value: 'online', label: 'Online' },
- { value: 'offline', label: 'Offline' },
- { value: 'unknown', label: 'Unknown' },
- ]}
+ options={STATUS_OPTIONS}
value={filterStatus}
onChange={setFilterStatus}
/>
@@ -250,6 +278,7 @@ export function WorkDevicesPage() {
{devices && (
Найдено: {devices.length} устройств
+ {selectedRowKeys.length > 0 && ` · Выбрано: ${selectedRowKeys.length}`}
)}
@@ -259,7 +288,7 @@ export function WorkDevicesPage() {
rowKey="id"
loading={isLoading}
size="middle"
- pagination={{ pageSize: 50 }}
+ pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
diff --git a/frontend/src/pages/WorkObjectDetail.tsx b/frontend/src/pages/WorkObjectDetail.tsx
index b0bca01..c71b8bc 100644
--- a/frontend/src/pages/WorkObjectDetail.tsx
+++ b/frontend/src/pages/WorkObjectDetail.tsx
@@ -28,6 +28,13 @@ import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs'
+const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
+const COL_SPAN = 7
+
+type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number; rackScope?: object }
+type DeviceRow = DeviceItem & { _type: 'device' }
+type TableRow = GroupRow | DeviceRow
+
export function WorkObjectDetailPage() {
const { id } = useParams<{ id: string }>()
const objId = Number(id)
@@ -77,6 +84,58 @@ export function WorkObjectDetailPage() {
)
}, [devices, searchText])
+ const groupedTableData = useMemo((): TableRow[] => {
+ const servers = filteredDevices.filter((d) => SERVER_CATEGORIES.has(d.category))
+ const rest = filteredDevices.filter((d) => !SERVER_CATEGORIES.has(d.category))
+
+ const rackMap = new Map()
+ for (const d of rest) {
+ const key = d.rack_name ?? null
+ if (!rackMap.has(key)) rackMap.set(key, { id: d.rack_id, items: [] })
+ rackMap.get(key)!.items.push(d)
+ }
+
+ const rows: TableRow[] = []
+
+ const addGroup = (label: string, key: string, items: DeviceItem[], rackScope?: object) => {
+ rows.push({
+ _type: 'group',
+ _key: key,
+ label,
+ count: items.length,
+ onlineCount: items.filter((d) => d.status === 'online').length,
+ rackScope,
+ })
+ items.forEach((d) => rows.push({ ...d, _type: 'device' }))
+ }
+
+ if (servers.length > 0) {
+ addGroup('Серверная инфраструктура', '__servers__', servers, {
+ type: 'category',
+ object_id: objId,
+ category: 'main_server',
+ })
+ }
+
+ // Sort racks by name
+ const rackEntries = [...rackMap.entries()].filter(([k]) => k !== null) as [string, { id: number | null; items: DeviceItem[] }][]
+ rackEntries.sort(([a], [b]) => a.localeCompare(b))
+
+ for (const [rackName, { id: rackId, items }] of rackEntries) {
+ addGroup(
+ rackName,
+ `rack:${rackName}`,
+ items,
+ rackId != null ? { type: 'device_list', ids: items.map((d) => d.id) } : undefined,
+ )
+ }
+
+ const noRack = rackMap.get(null)?.items ?? []
+ if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack)
+
+ return rows
+ }, [filteredDevices, objId])
+
if (objLoading) return
const deviceColumns = [
@@ -84,77 +143,89 @@ export function WorkObjectDetailPage() {
title: 'IP',
dataIndex: 'ip',
key: 'ip',
- render: (ip: string) => {ip},
+ onCell: (row: TableRow) =>
+ row._type === 'group'
+ ? { colSpan: COL_SPAN, style: { background: '#f5f5f5', padding: '6px 16px', borderBottom: '1px solid #e8e8e8' } }
+ : {},
+ render: (_: unknown, row: TableRow) => {
+ if (row._type === 'group') {
+ return (
+
+ {row.label}
+ {row.count} устройств
+ {row.onlineCount > 0 && Online: {row.onlineCount}}
+ {row.rackScope && (
+ }
+ onClick={() => openTaskModal(row.rackScope!, `Задача: ${row.label}`)}
+ >
+ Задача на группу
+
+ )}
+
+ )
+ }
+ return {(row as DeviceRow).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
- },
+ onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
+ render: (_: unknown, row: TableRow) =>
+ row._type === 'group' ? null : ((row as DeviceRow).hostname ?? '—'),
},
{
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,
+ onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
+ render: (_: unknown, row: TableRow) =>
+ row._type === 'group' ? null : ,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
- render: (s: string) => ,
+ onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
+ render: (_: unknown, row: TableRow) =>
+ row._type === 'group' ? null : ,
},
{
title: 'Последний ping',
dataIndex: 'last_seen',
key: 'last_seen',
- render: (ts: string | null, row: DeviceItem) =>
- ts ? (
-
- {row.last_ping_ms}ms
+ onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
+ render: (_: unknown, row: TableRow) => {
+ if (row._type === 'group') return null
+ const d = row as DeviceRow
+ return d.last_seen ? (
+
+ {d.last_ping_ms}ms
- ) : '—',
+ ) : '—'
+ },
},
{
title: '',
key: 'actions',
width: 110,
- render: (_: unknown, row: DeviceItem) => (
- }
- onClick={() => openTaskModal({ type: 'device', id: row.id }, `Задача: ${row.hostname ?? row.ip}`)}
- >
- Задача
-
- ),
+ onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
+ render: (_: unknown, row: TableRow) => {
+ if (row._type === 'group') return null
+ const d = row as DeviceRow
+ return (
+ }
+ onClick={() => openTaskModal({ type: 'device', id: d.id }, `Задача: ${d.hostname ?? d.ip}`)}
+ >
+ Задача
+
+ )
+ },
},
]
@@ -217,12 +288,13 @@ export function WorkObjectDetailPage() {
/>
(row._type === 'group' ? `group:${row._key}` : `dev:${(row as DeviceRow).id}`)}
loading={devLoading}
size="middle"
- pagination={{ pageSize: 50 }}
+ pagination={false}
+ showHeader={groupedTableData.some((r) => r._type === 'device')}
/>
{/* Task Modal */}