This commit is contained in:
dv 2026-04-09 10:59:52 +03:00
commit 941258fbb6
5 changed files with 234 additions and 76 deletions

View file

@ -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,

View file

@ -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 []

View file

@ -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) => {

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()
@ -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[]),

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 } : {},
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 */}