Compare commits

...

3 commits

Author SHA1 Message Date
dv
220799d853 фикс ворк 2026-04-08 23:16:18 +03:00
dv
d0c2054c43 Merge branch 'main' of https://git.ihateamerica.ru/dv/utp_service 2026-04-08 23:00:09 +03:00
dv
2af5bc8afd claude md 2026-04-08 10:38:18 +03:00
2 changed files with 166 additions and 65 deletions

View file

@ -14,10 +14,9 @@ import {
Typography, Typography,
message, message,
} from 'antd' } from 'antd'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAllDevices, useObjectsMeta, type DeviceItem } from '../api/objects' import { useAllDevices, useObjectsMeta, useObjects, type DeviceItem } from '../api/objects'
import { useObjects } from '../api/objects'
import { useActions, useCreateTask } from '../api/tasks' import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -34,6 +33,13 @@ const CATEGORY_OPTIONS = [
{ value: 'other', label: 'Другое' }, { 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() { export function WorkDevicesPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: meta } = useObjectsMeta() const { data: meta } = useObjectsMeta()
@ -62,6 +68,38 @@ export function WorkDevicesPage() {
const currentAction = actions?.find((a) => a.name === selectedAction) 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 = () => { const openTaskModal = () => {
setTaskModal(true) setTaskModal(true)
setSelectedAction('') setSelectedAction('')
@ -72,7 +110,6 @@ export function WorkDevicesPage() {
const values = await form.validateFields() const values = await form.validateFields()
const { action, ...params } = values const { action, ...params } = values
// If specific devices selected — run on each; else run with current filters as scope
const scope = const scope =
selectedRowKeys.length > 0 selectedRowKeys.length > 0
? { type: 'device_list', ids: selectedRowKeys } ? { 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 = [ const columns = [
{ {
title: 'IP', title: 'IP',
@ -206,7 +238,7 @@ export function WorkDevicesPage() {
allowClear allowClear
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))} options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
value={filterCity} value={filterCity}
onChange={setFilterCity} onChange={handleCityChange}
/> />
</Col> </Col>
<Col span={4}> <Col span={4}>
@ -216,7 +248,7 @@ export function WorkDevicesPage() {
allowClear allowClear
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))} options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
value={filterClient} value={filterClient}
onChange={setFilterClient} onChange={handleClientChange}
/> />
</Col> </Col>
<Col span={6}> <Col span={6}>
@ -236,11 +268,7 @@ export function WorkDevicesPage() {
placeholder="Статус" placeholder="Статус"
style={{ width: '100%' }} style={{ width: '100%' }}
allowClear allowClear
options={[ options={STATUS_OPTIONS}
{ value: 'online', label: 'Online' },
{ value: 'offline', label: 'Offline' },
{ value: 'unknown', label: 'Unknown' },
]}
value={filterStatus} value={filterStatus}
onChange={setFilterStatus} onChange={setFilterStatus}
/> />
@ -250,6 +278,7 @@ export function WorkDevicesPage() {
{devices && ( {devices && (
<Typography.Text type="secondary" style={{ marginBottom: 8, display: 'block' }}> <Typography.Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
Найдено: {devices.length} устройств Найдено: {devices.length} устройств
{selectedRowKeys.length > 0 && ` · Выбрано: ${selectedRowKeys.length}`}
</Typography.Text> </Typography.Text>
)} )}
@ -259,7 +288,7 @@ export function WorkDevicesPage() {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
size="middle" size="middle"
pagination={{ pageSize: 50 }} pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
rowSelection={{ rowSelection={{
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]), onChange: (keys) => setSelectedRowKeys(keys as number[]),

View file

@ -28,6 +28,13 @@ import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs' 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() { export function WorkObjectDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const objId = Number(id) const objId = Number(id)
@ -77,6 +84,58 @@ export function WorkObjectDetailPage() {
) )
}, [devices, searchText]) }, [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<string | null, { id: number | null; items: DeviceItem[] }>()
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 <Spin /> if (objLoading) return <Spin />
const deviceColumns = [ const deviceColumns = [
@ -84,77 +143,89 @@ export function WorkObjectDetailPage() {
title: 'IP', title: 'IP',
dataIndex: 'ip', dataIndex: 'ip',
key: 'ip', key: 'ip',
render: (ip: string) => <code>{ip}</code>, 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 (
<Space size={8}>
<Typography.Text strong style={{ fontSize: 13 }}>{row.label}</Typography.Text>
<Tag style={{ marginLeft: 2 }}>{row.count} устройств</Tag>
{row.onlineCount > 0 && <Tag color="green">Online: {row.onlineCount}</Tag>}
{row.rackScope && (
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => openTaskModal(row.rackScope!, `Задача: ${row.label}`)}
>
Задача на группу
</Button>
)}
</Space>
)
}
return <code>{(row as DeviceRow).ip}</code>
},
}, },
{ {
title: 'Hostname', title: 'Hostname',
dataIndex: 'hostname', dataIndex: 'hostname',
key: 'hostname', key: 'hostname',
render: (h: string | null) => h ?? '—', onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
}, render: (_: unknown, row: TableRow) =>
{ row._type === 'group' ? null : ((row as DeviceRow).hostname ?? '—'),
title: 'Стойка',
dataIndex: 'rack_name',
key: 'rack_name',
render: (name: string | null) =>
name ? <Tag color="blue">{name}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
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: 'Категория', title: 'Категория',
dataIndex: 'category', dataIndex: 'category',
key: 'category', key: 'category',
render: (c: string) => <CategoryTag category={c} />, onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
filters: [ render: (_: unknown, row: TableRow) =>
{ text: 'Главный сервер', value: 'main_server' }, row._type === 'group' ? null : <CategoryTag category={(row as DeviceRow).category} />,
{ 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: 'Статус', title: 'Статус',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />, onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
render: (_: unknown, row: TableRow) =>
row._type === 'group' ? null : <DeviceStatusBadge status={(row as DeviceRow).status} />,
}, },
{ {
title: 'Последний ping', title: 'Последний ping',
dataIndex: 'last_seen', dataIndex: 'last_seen',
key: 'last_seen', key: 'last_seen',
render: (ts: string | null, row: DeviceItem) => onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
ts ? ( render: (_: unknown, row: TableRow) => {
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}> if (row._type === 'group') return null
<span>{row.last_ping_ms}ms</span> const d = row as DeviceRow
return d.last_seen ? (
<Tooltip title={dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
<span>{d.last_ping_ms}ms</span>
</Tooltip> </Tooltip>
) : '—', ) : '—'
},
}, },
{ {
title: '', title: '',
key: 'actions', key: 'actions',
width: 110, width: 110,
render: (_: unknown, row: DeviceItem) => ( onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
<Button render: (_: unknown, row: TableRow) => {
size="small" if (row._type === 'group') return null
icon={<PlayCircleOutlined />} const d = row as DeviceRow
onClick={() => openTaskModal({ type: 'device', id: row.id }, `Задача: ${row.hostname ?? row.ip}`)} return (
> <Button
Задача size="small"
</Button> icon={<PlayCircleOutlined />}
), onClick={() => openTaskModal({ type: 'device', id: d.id }, `Задача: ${d.hostname ?? d.ip}`)}
>
Задача
</Button>
)
},
}, },
] ]
@ -217,12 +288,13 @@ export function WorkObjectDetailPage() {
/> />
<Table <Table
dataSource={filteredDevices} dataSource={groupedTableData}
columns={deviceColumns} columns={deviceColumns}
rowKey="id" rowKey={(row) => (row._type === 'group' ? `group:${row._key}` : `dev:${(row as DeviceRow).id}`)}
loading={devLoading} loading={devLoading}
size="middle" size="middle"
pagination={{ pageSize: 50 }} pagination={false}
showHeader={groupedTableData.some((r) => r._type === 'device')}
/> />
{/* Task Modal */} {/* Task Modal */}