From 2af5bc8afd01e3f930178e4bd3e99351770cb15f Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 10:38:18 +0300 Subject: [PATCH 1/5] claude md --- CLAUDE.md | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bdcf1e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,208 @@ +# UTP Service — Project Reference + +Fleet management web service for parking/access control equipment (CAPS system). + +## Stack + +| Layer | Tech | +|---|---| +| Backend | FastAPI + SQLAlchemy 2.0 async (asyncpg), Alembic, asyncssh, psycopg3 | +| Frontend | React 18 + TypeScript, Vite, Ant Design 5, TanStack React Query 5, Zustand | +| DB | PostgreSQL 16 | +| Infra | Docker Compose | + +Run: `docker compose up` — backend :8005, frontend :3005, postgres :5432 + +## Project Layout + +``` +utp_service/ +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI app, routers, lifespan +│ │ ├── config.py # Settings (DATABASE_URL, SECRET_KEY, ENCRYPTION_KEY) +│ │ ├── dependencies.py # get_db, get_current_user +│ │ ├── models/ # SQLAlchemy ORM models +│ │ ├── routers/ # API route handlers +│ │ ├── schemas/ # Pydantic request/response schemas +│ │ ├── services/ # Business logic +│ │ ├── plugins/ # Plugin registry + builtin plugins +│ │ ├── workers/ # Background workers (monitor, startup) +│ │ └── middleware/auth.py # Auth middleware +│ └── alembic/versions/ # Numbered migrations 0001–0009 +└── frontend/ + └── src/ + ├── App.tsx # React Router routes + ├── pages/ # Page-level components + ├── api/ # React Query hooks + API calls + ├── hooks/ # useSSE, useMonitorSSE + ├── components/ # AppLayout, StatusBadge + └── store/auth.ts # Zustand auth store +``` + +## Database Session Pattern + +`autoflush=False` — must call `await db.flush()` explicitly after `db.add()` before any SELECT that needs to see the new rows. `session.begin()` auto-commits on handler return. + +```python +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + async with session.begin(): + yield session +``` + +## Models + +### Object (`objects` table) +Represents a physical site/parking lot. + +Key fields: `name`, `description`, `city`, `client`, `server_ip` (main CAPS server IP), +`db_host/port/name/user/db_pass_enc/db_table/db_via_tunnel` (CAPS PostgreSQL connection), +`ssh_user/ssh_pass_enc/ssh_port` (SSH credentials, shared across site), +`is_active`, `tags` (M2M → Tag) + +### Device (`devices` table) +Network device at a site. + +Key fields: `object_id` (FK), `ip` (unique per object: `uq_device_object_ip`), +`hostname`, `mac`, `category`, `role`, `location`, `vendor`, `model`, +`source` (`manual`/`db_sync`/`auto_ssh`/`csv`/`sheets`), +`status` (`unknown`/`online`/`offline`/`degraded`), `monitor_enabled`, +`rack_id` (FK → Rack), `connection_params` (JSONB), +`device_meta` (JSONB — e.g. `{"plugin": "camera_1", "cls": "..."}`), +`capabilities` (ARRAY), `ssh_user_override/ssh_pass_override_enc/ssh_port_override` + +Device categories: `main_server`, `vm`, `router`, `embedded`, `camera`, `io_board`, `bank_terminal`, `cash_register`, `other` +Device roles: `plate`, `face`, `overview`, `other` +Device locations: `server_room`, `rack`, `street` + +### Rack (`racks` table) +`id`, `object_id`, `name`, `description`, `location`, `zone_id` (FK → Zone) + +### Zone (`zones` table) +`id`, `object_id`, `name`, `description`, racks relationship + +### Task (`tasks` table) +`id`, `type`, `status` (pending/running/success/partial/failed/stale/cancelled), +`target_scope` (JSONB — see Task System), `params` (JSONB), `result` (JSONB), +`created_by`, timestamps, → TaskEvent, TaskResult relationships + +### Alert (`alerts` table) +`id`, `device_id`, `status` (open/acknowledged/resolved), `message`, timestamps + +### ConfigSnapshot / SnapshotContent +Content-addressed: `SnapshotContent(sha256 PK, content)`, `ConfigSnapshot(device_id, sha256 FK, path, captured_at)` + +### Tag (`tags` table) +`id`, `name`, `color`, `tag_type`; M2M with objects via `object_tags` + +## Routers (all under `/api/v1/`) + +| Router file | Prefix | Key endpoints | +|---|---|---| +| auth.py | /auth | POST login, POST refresh, POST logout | +| objects.py | /objects | CRUD + GET /meta (cities/clients) + POST /{id}/discover + POST /{id}/sync | +| devices.py | /objects/{id}/devices | CRUD + POST /import + POST /import/preview | +| global_devices.py | /devices | GET all devices across objects (filters: category, city, client, object_id, status) | +| racks.py | /objects/{id}/racks | CRUD | +| zones.py | /objects/{id}/zones | CRUD | +| tasks.py | /tasks | POST create + GET list/detail + GET /{id}/events (SSE) | +| events.py | /events | GET SSE stream (task_id param) | +| alerts.py | /alerts | GET list + POST /{id}/acknowledge + POST /{id}/resolve | +| snapshots.py | /snapshots | GET list + GET /{id}/diff | +| tags.py | /tags | CRUD | +| admin.py | /admin | User mgmt + plugin control + health | + +## Services + +| File | Purpose | +|---|---| +| device_discovery.py | SSH-based discovery: SSH → rack PC → parse /etc/caps/config.ini → extract plugin IPs → upsert devices | +| object_db.py | Sync devices from CAPS PostgreSQL DB | +| task.py | Task dispatch, per-device async execution (concurrency: 20) | +| ssh.py | asyncssh client with concurrency limits | +| crypto.py | Fernet encrypt/decrypt for passwords (db_pass_enc, ssh_pass_enc) | +| sse.py | SSEManager pub/sub for Server-Sent Events | +| camera.py | Hikvision ISAPI HTTP client | +| snapshot.py | SHA-256 content-addressed config snapshot storage | +| sheets.py | Google Sheets device import (gspread) | +| auth.py | JWT + bcrypt, access (15min) + refresh (7d) tokens | + +## SSH Discovery (`device_discovery.py`) + +Flow: +1. Resolve CAPS DB connection: use explicit `obj.db_*` fields OR autodetect by SSHing into `obj.server_ip` → read `/etc/caps/config.ini` → extract `db_uri` +2. Query racks table → get `(external_id, rack_host_ip, rack_name)` tuples +3. For each rack: SSH into `rack_host_ip` → read `/etc/caps/config.ini` +4. Parse config with `RawConfigParser(strict=False)` (strict=False needed for duplicate keys like `ini=` in uwsgi) +5. Extract devices from `[plugin:*]` sections by parsing URI fields (tcp://, http://) — skip serial://, localhost +6. Upsert: SELECT by `(object_id, ip)`, INSERT or UPDATE category/role/hostname +7. `await db.flush()` after each rack (autoflush=False — without flush, SELECT won't see pending db.add() rows) + +Hostname format: `rack{rack_name}-{plugin_name}` e.g. `rack01-camera_grz` + +## Task System + +`target_scope` JSONB shapes: +- `{"type": "device", "id": 123}` +- `{"type": "object", "id": 4}` +- `{"type": "category", "object_id": 4, "category": "main_server"}` +- `{"type": "device_list", "ids": [1, 2, 3]}` + +Task events streamed via SSE at `GET /api/v1/events?task_id={id}` +Monitor events at `GET /api/v1/events?task_id=__monitor__` + +SSE fix: `onerror` must `throw err` to prevent fetchEventSource auto-retry loop on 401. + +## Frontend Routes + +``` +/ → redirect to /inventory/objects +/inventory/objects → ObjectsPage (mode=inventory) — city-grouped list +/inventory/objects/:id → InventoryObjectDetailPage — device CRUD, SSH discovery +/inventory/objects/:id/snapshots → SnapshotsPage +/work/objects → ObjectsPage (mode=work) — city-grouped list +/work/objects/:id → WorkObjectDetailPage — task execution, device status +/work/devices → WorkDevicesPage — global device list with filters +``` + +Layout nav: ИНВЕНТАРЬ (DatabaseOutlined) | РАБОТА (ControlOutlined, AppstoreOutlined) + +## Frontend API Hooks (`src/api/`) + +- `objects.ts`: useObjects, useObject, useCreateObject, useUpdateObject, useDeleteObject, useObjectsMeta (cities/clients autocomplete), useDiscoverObject, useSyncFromDB, useSyncFromSheets +- `devices.ts`: useDevices, useDevice, useCreateDevice, useUpdateDevice, useDeleteDevice, useImportDevicesPreview, useImportDevices, useAllDevices (global) +- `tasks.ts`: useTasks, useTaskDetail, useCreateTask, useTaskSSE +- `alerts.ts`: useAlerts, useAcknowledgeAlert, useResolveAlert +- `snapshots.ts`: useSnapshots, useSnapshotDetail, useSnapshotDiff, useCaptureSnapshot + +## Plugins (builtin) + +Located in `backend/app/plugins/builtin/`: +- `camera/`: get_status (Hikvision ISAPI), set_ntp +- `mikrotik/`: get_config, get_ntp, set_ntp (RouterOS via SSH) +- `ssh/`: debian_system_info, get_file, get_timedatectl, ssh_command +- `common/`: ping + +## Migrations + +Apply via: `docker compose exec backend alembic upgrade head` + +Files in `backend/alembic/versions/`: +- 0001: initial schema +- 0002: tasks +- 0003: racks +- 0004: zones +- 0005: snapshots +- 0006: alerts +- 0007: plugins +- 0008: inventory (vendor, location, connection_params on devices) +- 0009: city, client on objects + +## Known Gotchas + +- `autoflush=False`: always `await db.flush()` after `db.add()` before subsequent SELECTs in the same transaction +- `await db.refresh(obj)` — no attribute_names arg, otherwise server-generated columns (updated_at) aren't refreshed +- `RawConfigParser(strict=False)` — CAPS configs have duplicate keys in [uwsgi] section +- SSH discovery: `onerror(err) { throw err }` in fetchEventSource to stop auto-retry on non-200 +- Encrypted fields stored as Fernet ciphertext; use `crypto.encrypt()` / `crypto.decrypt()` From 220799d853c9fa4f8e78972d30b746798b18c51b Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 23:16:18 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/WorkDevices.tsx | 63 ++++++--- frontend/src/pages/WorkObjectDetail.tsx | 168 +++++++++++++++++------- 2 files changed, 166 insertions(+), 65 deletions(-) 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 */} From 01056a87b520e58bc69e4ae02e1e20542a924ad7 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 23:20:01 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D1=8B=D1=8B=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/client.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8fb2d1d..07b27e6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,21 @@ import axios from 'axios' 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 api.interceptors.request.use((config) => { From b618bdf0da87ed20f0a334d609ad13c94594ef63 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 23:27:25 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=D1=83=D1=83=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/global_devices.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/global_devices.py b/backend/app/routers/global_devices.py index f2fc6f0..183a7cb 100644 --- a/backend/app/routers/global_devices.py +++ b/backend/app/routers/global_devices.py @@ -1,9 +1,12 @@ """Global devices endpoint — cross-object device listing with filters.""" +import logging from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy import select + +logger = logging.getLogger(__name__) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -18,7 +21,7 @@ router = APIRouter(prefix="/api/v1/devices", tags=["devices-global"]) @router.get("", response_model=list[DeviceRead]) async def list_all_devices( - category: Optional[list[str]] = None, + category: Optional[list[str]] = Query(default=None), city: Optional[str] = None, client: Optional[str] = None, object_id: Optional[int] = None, @@ -34,6 +37,8 @@ async def list_all_devices( - All bank terminals for a client: ?category=bank_terminal&client=Авангард - All devices in a city: ?city=Санкт-Петербург """ + logger.info("list_all_devices called: category=%r city=%r client=%r object_id=%r status=%r", category, city, client, object_id, status) + query = ( select(Device) .join(Object, Device.object_id == Object.id) From 0c6b9494242b279b5ad1cd3a9c3d77309da37e1d Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 23:31:07 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D1=8B=D0=B2=D1=8B=D1=84=D0=B2=D1=84=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/global_devices.py | 5 ---- backend/app/services/task.py | 26 ++++++++++++++++++-- frontend/src/pages/WorkDevices.tsx | 35 +++++++++++++++++++++------ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/backend/app/routers/global_devices.py b/backend/app/routers/global_devices.py index 183a7cb..0b40607 100644 --- a/backend/app/routers/global_devices.py +++ b/backend/app/routers/global_devices.py @@ -1,12 +1,9 @@ """Global devices endpoint — cross-object device listing with filters.""" -import logging from typing import Optional from fastapi import APIRouter, Depends, Query from sqlalchemy import select - -logger = logging.getLogger(__name__) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -37,8 +34,6 @@ async def list_all_devices( - All bank terminals for a client: ?category=bank_terminal&client=Авангард - All devices in a city: ?city=Санкт-Петербург """ - logger.info("list_all_devices called: category=%r city=%r client=%r object_id=%r status=%r", category, city, client, object_id, status) - query = ( select(Device) .join(Object, Device.object_id == Object.id) diff --git a/backend/app/services/task.py b/backend/app/services/task.py index 92c99a8..6a5884a 100644 --- a/backend/app/services/task.py +++ b/backend/app/services/task.py @@ -49,6 +49,8 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]: scope_type = scope.get("type") if scope_type == "device": query = query.where(Device.id == scope["id"]) + elif scope_type == "device_list": + query = query.where(Device.id.in_(scope["ids"])) elif scope_type == "object": query = query.where(Device.object_id == scope["id"]) elif scope_type == "rack": @@ -63,17 +65,37 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]: return [] query = query.where(Device.rack_id.in_(rack_ids)) 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: query = query.where(Device.object_id.in_(scope["object_ids"])) elif "object_id" in scope: query = query.where(Device.object_id == scope["object_id"]) 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: query = query.where(Device.object_id.in_(scope["object_ids"])) elif "object_id" in scope: 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: return [] diff --git a/frontend/src/pages/WorkDevices.tsx b/frontend/src/pages/WorkDevices.tsx index 5468482..0fa14c0 100644 --- a/frontend/src/pages/WorkDevices.tsx +++ b/frontend/src/pages/WorkDevices.tsx @@ -52,6 +52,7 @@ export function WorkDevicesPage() { const [filterClient, setFilterClient] = useState() const [filterObjectId, setFilterObjectId] = useState() const [filterStatus, setFilterStatus] = useState() + const [searchText, setSearchText] = useState('') const [selectedRowKeys, setSelectedRowKeys] = useState([]) const [taskModal, setTaskModal] = useState(false) @@ -68,6 +69,14 @@ export function WorkDevicesPage() { 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 ?? []) @@ -275,15 +284,27 @@ export function WorkDevicesPage() { - {devices && ( - - Найдено: {devices.length} устройств - {selectedRowKeys.length > 0 && ` · Выбрано: ${selectedRowKeys.length}`} - - )} + + + setSearchText(e.target.value)} + /> + + + {devices && ( + + {searchText ? `${displayedDevices.length} из ${devices.length}` : `${devices.length} устройств`} + {selectedRowKeys.length > 0 && ` · Выбрано: ${selectedRowKeys.length}`} + + )} + +