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 && ( + + )} + + ) + } + 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) => ( - - ), + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => { + if (row._type === 'group') return null + const d = row as DeviceRow + return ( + + ) + }, }, ] @@ -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 */}