Merge branch 'main' of https://git.ihateamerica.ru/dv/utp_service
This commit is contained in:
commit
941258fbb6
5 changed files with 234 additions and 76 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -18,7 +18,7 @@ router = APIRouter(prefix="/api/v1/devices", tags=["devices-global"])
|
||||||
|
|
||||||
@router.get("", response_model=list[DeviceRead])
|
@router.get("", response_model=list[DeviceRead])
|
||||||
async def list_all_devices(
|
async def list_all_devices(
|
||||||
category: Optional[list[str]] = None,
|
category: Optional[list[str]] = Query(default=None),
|
||||||
city: Optional[str] = None,
|
city: Optional[str] = None,
|
||||||
client: Optional[str] = None,
|
client: Optional[str] = None,
|
||||||
object_id: Optional[int] = None,
|
object_id: Optional[int] = None,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]:
|
||||||
scope_type = scope.get("type")
|
scope_type = scope.get("type")
|
||||||
if scope_type == "device":
|
if scope_type == "device":
|
||||||
query = query.where(Device.id == scope["id"])
|
query = query.where(Device.id == scope["id"])
|
||||||
|
elif scope_type == "device_list":
|
||||||
|
query = query.where(Device.id.in_(scope["ids"]))
|
||||||
elif scope_type == "object":
|
elif scope_type == "object":
|
||||||
query = query.where(Device.object_id == scope["id"])
|
query = query.where(Device.object_id == scope["id"])
|
||||||
elif scope_type == "rack":
|
elif scope_type == "rack":
|
||||||
|
|
@ -63,17 +65,37 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]:
|
||||||
return []
|
return []
|
||||||
query = query.where(Device.rack_id.in_(rack_ids))
|
query = query.where(Device.rack_id.in_(rack_ids))
|
||||||
elif scope_type == "category":
|
elif scope_type == "category":
|
||||||
query = query.where(Device.category == scope["value"])
|
# support both "value" (legacy) and "category" key
|
||||||
|
cat = scope.get("category") or scope.get("value")
|
||||||
|
if cat:
|
||||||
|
query = query.where(Device.category == cat)
|
||||||
if "object_ids" in scope:
|
if "object_ids" in scope:
|
||||||
query = query.where(Device.object_id.in_(scope["object_ids"]))
|
query = query.where(Device.object_id.in_(scope["object_ids"]))
|
||||||
elif "object_id" in scope:
|
elif "object_id" in scope:
|
||||||
query = query.where(Device.object_id == scope["object_id"])
|
query = query.where(Device.object_id == scope["object_id"])
|
||||||
elif scope_type == "role":
|
elif scope_type == "role":
|
||||||
query = query.where(Device.role == scope["value"])
|
role = scope.get("role") or scope.get("value")
|
||||||
|
if role:
|
||||||
|
query = query.where(Device.role == role)
|
||||||
if "object_ids" in scope:
|
if "object_ids" in scope:
|
||||||
query = query.where(Device.object_id.in_(scope["object_ids"]))
|
query = query.where(Device.object_id.in_(scope["object_ids"]))
|
||||||
elif "object_id" in scope:
|
elif "object_id" in scope:
|
||||||
query = query.where(Device.object_id == scope["object_id"])
|
query = query.where(Device.object_id == scope["object_id"])
|
||||||
|
elif scope_type == "filter":
|
||||||
|
# Cross-object filter scope (from WorkDevices global page)
|
||||||
|
from app.models.object import Object as Obj
|
||||||
|
if scope.get("category"):
|
||||||
|
cats = scope["category"]
|
||||||
|
if isinstance(cats, list):
|
||||||
|
query = query.where(Device.category.in_(cats))
|
||||||
|
else:
|
||||||
|
query = query.where(Device.category == cats)
|
||||||
|
if scope.get("object_id"):
|
||||||
|
query = query.where(Device.object_id == scope["object_id"])
|
||||||
|
if scope.get("city"):
|
||||||
|
query = query.where(Obj.city == scope["city"])
|
||||||
|
if scope.get("client"):
|
||||||
|
query = query.where(Obj.client == scope["client"])
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
|
|
||||||
export const api = axios.create({ baseURL: '/' })
|
export const api = axios.create({
|
||||||
|
baseURL: '/',
|
||||||
|
paramsSerializer: (params) => {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
for (const [key, val] of Object.entries(params)) {
|
||||||
|
if (val === undefined || val === null) continue
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
val.forEach((v) => sp.append(key, String(v)))
|
||||||
|
} else {
|
||||||
|
sp.append(key, String(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sp.toString()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Inject access token on every request
|
// Inject access token on every request
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -46,6 +52,7 @@ export function WorkDevicesPage() {
|
||||||
const [filterClient, setFilterClient] = useState<string | undefined>()
|
const [filterClient, setFilterClient] = useState<string | undefined>()
|
||||||
const [filterObjectId, setFilterObjectId] = useState<number | undefined>()
|
const [filterObjectId, setFilterObjectId] = useState<number | undefined>()
|
||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>()
|
const [filterStatus, setFilterStatus] = useState<string | undefined>()
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
|
||||||
|
|
||||||
const [taskModal, setTaskModal] = useState(false)
|
const [taskModal, setTaskModal] = useState(false)
|
||||||
|
|
@ -62,6 +69,46 @@ export function WorkDevicesPage() {
|
||||||
|
|
||||||
const currentAction = actions?.find((a) => a.name === selectedAction)
|
const currentAction = actions?.find((a) => a.name === selectedAction)
|
||||||
|
|
||||||
|
const displayedDevices = 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])
|
||||||
|
|
||||||
|
// 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 +119,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 +140,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 +247,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 +257,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,30 +277,39 @@ 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}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={12} style={{ marginBottom: 12 }} align="middle">
|
||||||
|
<Col flex="auto">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Поиск по IP или hostname..."
|
||||||
|
allowClear
|
||||||
|
style={{ maxWidth: 360 }}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
{devices && (
|
{devices && (
|
||||||
<Typography.Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
|
<Typography.Text type="secondary">
|
||||||
Найдено: {devices.length} устройств
|
{searchText ? `${displayedDevices.length} из ${devices.length}` : `${devices.length} устройств`}
|
||||||
|
{selectedRowKeys.length > 0 && ` · Выбрано: ${selectedRowKeys.length}`}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
dataSource={devices ?? []}
|
dataSource={displayedDevices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
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[]),
|
||||||
|
|
|
||||||
|
|
@ -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 } : {},
|
||||||
|
render: (_: unknown, row: TableRow) => {
|
||||||
|
if (row._type === 'group') return null
|
||||||
|
const d = row as DeviceRow
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
onClick={() => openTaskModal({ type: 'device', id: row.id }, `Задача: ${row.hostname ?? row.ip}`)}
|
onClick={() => openTaskModal({ type: 'device', id: d.id }, `Задача: ${d.hostname ?? d.ip}`)}
|
||||||
>
|
>
|
||||||
Задача
|
Задача
|
||||||
</Button>
|
</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 */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue