From 68b9fa741794e3b45095b91f076ee71edb11821d Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 7 Apr 2026 00:52:45 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF2-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/object_db.py | 65 +++-- docker/Dockerfile.frontend | 6 + docker/docker-compose.yml | 13 + frontend/index.html | 12 + frontend/package.json | 29 +++ frontend/src/App.tsx | 35 +++ frontend/src/api/client.ts | 49 ++++ frontend/src/api/objects.ts | 120 +++++++++ frontend/src/api/tasks.ts | 76 ++++++ frontend/src/components/AppLayout.tsx | 79 ++++++ frontend/src/components/StatusBadge.tsx | 40 +++ frontend/src/hooks/useSSE.ts | 40 +++ frontend/src/main.tsx | 32 +++ frontend/src/pages/Login.tsx | 49 ++++ frontend/src/pages/ObjectDetail.tsx | 328 ++++++++++++++++++++++++ frontend/src/pages/Objects.tsx | 240 +++++++++++++++++ frontend/src/pages/TaskDetail.tsx | 184 +++++++++++++ frontend/src/pages/Tasks.tsx | 69 +++++ frontend/src/store/auth.ts | 25 ++ frontend/tsconfig.json | 19 ++ frontend/vite.config.ts | 16 ++ 21 files changed, 1500 insertions(+), 26 deletions(-) create mode 100644 docker/Dockerfile.frontend create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/objects.ts create mode 100644 frontend/src/api/tasks.ts create mode 100644 frontend/src/components/AppLayout.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/hooks/useSSE.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/ObjectDetail.tsx create mode 100644 frontend/src/pages/Objects.tsx create mode 100644 frontend/src/pages/TaskDetail.tsx create mode 100644 frontend/src/pages/Tasks.tsx create mode 100644 frontend/src/store/auth.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/backend/app/services/object_db.py b/backend/app/services/object_db.py index a911722..4485b0c 100644 --- a/backend/app/services/object_db.py +++ b/backend/app/services/object_db.py @@ -1,7 +1,14 @@ """ Connects to a site object's external PostgreSQL database and syncs devices. Uses short-lived psycopg3 connections (connect → query → disconnect). -SSH tunnel support is deferred to Этап 2 (asyncssh not yet available). + +Expected table schema (racks): + id TEXT — external identifier, e.g. "lane-1-1" + name TEXT — short label, e.g. "01" + type TEXT — "rack.lane" | "rack.payment" | ... + data JSONB — contains "host" (IP address) and other device-specific fields + +SSH tunnel support is deferred to Этап 2. """ from dataclasses import dataclass, field @@ -23,24 +30,23 @@ class SyncResult: errors: list[str] = field(default_factory=list) -# Maps type strings from object DB to our device categories +# rack.* type → our internal category _CATEGORY_MAP: dict[str, str] = { + "rack.lane": "embedded", + "rack.payment": "other", + "rack.server": "server", + "rack.camera": "camera", "raspberry": "raspberry", "rpi": "raspberry", - "raspberrypi": "raspberry", - "raspberry_pi": "raspberry", "mikrotik": "mikrotik", - "routeros": "mikrotik", "camera": "camera", - "cam": "camera", - "ipcam": "camera", - "embedded": "embedded", "server": "server", + "embedded": "embedded", } -def _map_category(raw: str) -> str: - if raw is None: +def _map_category(raw: str | None) -> str: + if not raw: return "other" return _CATEGORY_MAP.get(raw.strip().lower(), "other") @@ -48,7 +54,7 @@ def _map_category(raw: str) -> str: async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult: result = SyncResult() - if not all([obj.db_host, obj.db_name, obj.db_user, obj.db_pass_enc, obj.db_table, obj.db_col_ip]): + if not all([obj.db_host, obj.db_name, obj.db_user, obj.db_pass_enc, obj.db_table]): result.errors.append("Object DB connection is not fully configured") return result @@ -69,51 +75,58 @@ async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult: try: async with await psycopg.AsyncConnection.connect(dsn) as conn: - cols = [obj.db_col_ip] - if obj.db_col_type: - cols.append(obj.db_col_type) - - col_list = ", ".join(f'"{c}"' for c in cols) - rows = await conn.execute(f'SELECT {col_list} FROM "{obj.db_table}"') + # Pull id, name, type and host from the JSONB data column + rows = await conn.execute( + f'SELECT id, name, type, data->>\'host\' AS host FROM "{obj.db_table}"' + ) remote_rows = await rows.fetchall() except Exception as exc: result.errors.append(f"Cannot connect to object DB: {exc}") return result for row in remote_rows: - ip = str(row[0]).strip() if row[0] else None - raw_type = str(row[1]).strip() if len(row) > 1 and row[1] else None + external_id, name, rack_type, host = row[0], row[1], row[2], row[3] + ip = str(host).strip() if host else None if not ip: result.skipped += 1 continue - category = _map_category(raw_type) + category = _map_category(rack_type) + hostname = str(name).strip() if name else None - # Look for existing device by object + external source existing = await db.execute( select(Device).where( Device.object_id == obj.id, - Device.source == "auto_sync", - Device.ip == ip, + Device.external_id == str(external_id), ) ) device = existing.scalar_one_or_none() if device is not None: changed = False + if device.ip != ip: + device.ip = ip + changed = True + if device.hostname != hostname: + device.hostname = hostname + changed = True if device.category != category: device.category = category changed = True - result.updated += 1 if changed else 0 - result.skipped += 0 if changed else 1 + if changed: + result.updated += 1 + else: + result.skipped += 1 else: db.add(Device( object_id=obj.id, ip=ip, + hostname=hostname, category=category, source="auto_sync", - external_id=ip, + external_id=str(external_id), + role=rack_type or "unknown", )) result.created += 1 diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 0000000..a581c5e --- /dev/null +++ b/docker/Dockerfile.frontend @@ -0,0 +1,6 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +CMD ["npm", "run", "dev"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 96191a9..25303f4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -33,5 +33,18 @@ services: - ../backend:/app command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + frontend: + build: + context: ../frontend + dockerfile: ../docker/Dockerfile.frontend + restart: unless-stopped + ports: + - "3000:3000" + depends_on: + - backend + volumes: + - ../frontend:/app + - /app/node_modules + volumes: postgres_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..45b2a01 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Fleet Manager + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..91272f1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "fleet-manager-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "antd": "^5.22.0", + "@ant-design/icons": "^5.5.2", + "axios": "^1.7.9", + "@tanstack/react-query": "^5.62.0", + "zustand": "^5.0.2", + "@microsoft/fetch-event-source": "^2.0.1", + "dayjs": "^1.11.13" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e30adbc --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,35 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { AppLayout } from './components/AppLayout' +import { LoginPage } from './pages/Login' +import { ObjectDetailPage } from './pages/ObjectDetail' +import { ObjectsPage } from './pages/Objects' +import { TaskDetailPage } from './pages/TaskDetail' +import { TasksPage } from './pages/Tasks' +import { useAuthStore } from './store/auth' + +function RequireAuth({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.accessToken) + return token ? <>{children} : +} + +export default function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..8fb2d1d --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,49 @@ +import axios from 'axios' +import { useAuthStore } from '../store/auth' + +export const api = axios.create({ baseURL: '/' }) + +// Inject access token on every request +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +// On 401 — try refresh once, then logout +let refreshing: Promise | null = null + +api.interceptors.response.use( + (r) => r, + async (error) => { + const original = error.config + if (error.response?.status !== 401 || original._retried) { + return Promise.reject(error) + } + original._retried = true + + const { refreshToken, setTokens, clearTokens, username } = useAuthStore.getState() + if (!refreshToken) { + clearTokens() + return Promise.reject(error) + } + + if (!refreshing) { + refreshing = axios + .post('/api/v1/auth/refresh', { refresh_token: refreshToken }) + .then((r) => { + setTokens(r.data.access_token, r.data.refresh_token, username ?? '') + return r.data.access_token + }) + .catch(() => { + clearTokens() + throw new Error('Session expired') + }) + .finally(() => { refreshing = null }) + } + + const newToken = await refreshing + original.headers.Authorization = `Bearer ${newToken}` + return api(original) + } +) diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts new file mode 100644 index 0000000..9a21889 --- /dev/null +++ b/frontend/src/api/objects.ts @@ -0,0 +1,120 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from './client' + +export interface ObjectItem { + id: number + name: string + description: string | null + server_ip: string | null + ssh_user: string | null + db_host: string | null + is_active: boolean + tags: { id: number; name: string; color: string }[] + created_at: string +} + +export interface DeviceItem { + id: number + object_id: number + ip: string + hostname: string | null + mac: string | null + category: string + role: string + model: string | null + source: string + status: string + last_seen: string | null + last_ping_ms: number | null + monitor_enabled: boolean + is_active: boolean + capabilities: string[] +} + +export interface SyncResult { + created: number + updated: number + skipped: number + errors: string[] +} + +export const useObjects = (isActive?: boolean) => + useQuery({ + queryKey: ['objects', isActive], + queryFn: () => + api.get('/api/v1/objects', { + params: isActive !== undefined ? { is_active: isActive } : {}, + }).then((r) => r.data), + }) + +export const useObject = (id: number) => + useQuery({ + queryKey: ['objects', id], + queryFn: () => api.get(`/api/v1/objects/${id}`).then((r) => r.data), + }) + +export const useDevices = (objId: number) => + useQuery({ + queryKey: ['devices', objId], + queryFn: () => + api.get(`/api/v1/objects/${objId}/devices`).then((r) => r.data), + }) + +export interface ObjectCreatePayload { + name: string + description?: string + server_ip?: string + ssh_user?: string + ssh_password?: string + ssh_port?: number + db_host?: string + db_port?: number + db_name?: string + db_user?: string + db_password?: string + db_table?: string + db_col_ip?: string + db_col_type?: string + db_via_tunnel?: boolean + notes?: string +} + +export const useCreateObject = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: ObjectCreatePayload) => + api.post('/api/v1/objects', body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }), + }) +} + +export interface DeviceCreatePayload { + ip: string + hostname?: string + mac?: string + category: string + role: string + model?: string + ssh_user?: string + ssh_password?: string + ssh_port?: number + notes?: string +} + +export const useCreateDevice = (objId: number) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: DeviceCreatePayload) => + api.post(`/api/v1/objects/${objId}/devices`, body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + }) +} + +export const useSyncObject = (objId: number) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => + api.post(`/api/v1/objects/${objId}/sync`).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + }) +} diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts new file mode 100644 index 0000000..6e8b04b --- /dev/null +++ b/frontend/src/api/tasks.ts @@ -0,0 +1,76 @@ +import { useQuery, useMutation } from '@tanstack/react-query' +import { api } from './client' + +export interface TaskEvent { + id: number + message: string + level: string + created_at: string +} + +export interface TaskResult { + id: number + device_id: number + status: string + stdout: string | null + stderr: string | null + exit_code: number | null + duration_ms: number | null + executed_at: string | null +} + +export interface Task { + id: string + type: string + status: string + target_scope: Record + params: Record + result: { total: number; ok: number; failed: number } | null + error_message: string | null + created_by: number | null + created_at: string + started_at: string | null + finished_at: string | null +} + +export interface TaskDetail extends Task { + events: TaskEvent[] + device_results: TaskResult[] +} + +export interface ActionInfo { + name: string + display_name: string + compatible_categories: string[] + params_schema: { name: string; type: string; label: string; required?: boolean }[] +} + +export const useTasks = () => + useQuery({ + queryKey: ['tasks'], + queryFn: () => api.get('/api/v1/tasks').then((r) => r.data), + refetchInterval: 5000, + }) + +export const useTask = (id: string | undefined) => + useQuery({ + queryKey: ['tasks', id], + queryFn: () => api.get(`/api/v1/tasks/${id}`).then((r) => r.data), + enabled: !!id, + refetchInterval: (query) => { + const status = query.state.data?.status + return status === 'running' || status === 'pending' ? 2000 : false + }, + }) + +export const useActions = () => + useQuery({ + queryKey: ['actions'], + queryFn: () => api.get('/api/v1/tasks/actions').then((r) => r.data), + }) + +export const useCreateTask = () => + useMutation({ + mutationFn: (body: { type: string; target_scope: object; params: object }) => + api.post('/api/v1/tasks', body).then((r) => r.data), + }) diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..af6ffb0 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,79 @@ +import { + ApartmentOutlined, + LogoutOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' +import { Layout, Menu, Typography } from 'antd' +import { useNavigate, useLocation, Outlet } from 'react-router-dom' +import { useAuthStore } from '../store/auth' +import { api } from '../api/client' + +const { Sider, Content, Header } = Layout + +export function AppLayout() { + const navigate = useNavigate() + const location = useLocation() + const { username, refreshToken, clearTokens } = useAuthStore() + + const selectedKey = location.pathname.startsWith('/tasks') ? 'tasks' : 'objects' + + const handleLogout = async () => { + try { + if (refreshToken) await api.post('/api/v1/auth/logout', { refresh_token: refreshToken }) + } finally { + clearTokens() + navigate('/login') + } + } + + return ( + + +
+ + Fleet Manager + +
+ , + label: 'Объекты', + onClick: () => navigate('/objects'), + }, + { + key: 'tasks', + icon: , + label: 'Задачи', + onClick: () => navigate('/tasks'), + }, + ]} + /> +
+ , + label: username, + onClick: handleLogout, + }, + ]} + /> +
+ + + + + + + + ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..bc4cc5d --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,40 @@ +import { Badge, Tag } from 'antd' + +const DEVICE_STATUS: Record = { + online: { color: 'success', text: 'Online' }, + offline: { color: 'error', text: 'Offline' }, + unknown: { color: 'default', text: 'Unknown' }, +} + +const TASK_STATUS: Record = { + pending: { color: 'default', text: 'Ожидание' }, + running: { color: 'processing', text: 'Выполняется' }, + success: { color: 'success', text: 'Успешно' }, + partial: { color: 'warning', text: 'Частично' }, + failed: { color: 'error', text: 'Ошибка' }, + stale: { color: 'default', text: 'Прервано' }, + cancelled: { color: 'default', text: 'Отменено' }, +} + +const CATEGORY_COLOR: Record = { + raspberry: 'red', + mikrotik: 'blue', + camera: 'purple', + embedded: 'orange', + server: 'cyan', + other: 'default', +} + +export function DeviceStatusBadge({ status }: { status: string }) { + const cfg = DEVICE_STATUS[status] ?? { color: 'default', text: status } + return +} + +export function TaskStatusBadge({ status }: { status: string }) { + const cfg = TASK_STATUS[status] ?? { color: 'default', text: status } + return +} + +export function CategoryTag({ category }: { category: string }) { + return {category} +} diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts new file mode 100644 index 0000000..8e7ee1e --- /dev/null +++ b/frontend/src/hooks/useSSE.ts @@ -0,0 +1,40 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { useEffect, useRef } from 'react' +import { useAuthStore } from '../store/auth' + +interface SSEHandlers { + onTaskEvent?: (data: { message: string; level: string; ts: string }) => void + onTaskResult?: (data: { device_id: number; device_ip: string; status: string; duration_ms: number }) => void + onTaskStatus?: (data: { status: string; total: number; ok: number; failed: number }) => void +} + +export function useSSE(taskId: string | null | undefined, handlers: SSEHandlers) { + const accessToken = useAuthStore((s) => s.accessToken) + const handlersRef = useRef(handlers) + handlersRef.current = handlers + + useEffect(() => { + if (!taskId || !accessToken) return + const ctrl = new AbortController() + + fetchEventSource(`/api/v1/events?task_id=${taskId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: ctrl.signal, + onmessage(ev) { + try { + const data = JSON.parse(ev.data) + if (ev.event === 'task_event') handlersRef.current.onTaskEvent?.(data) + else if (ev.event === 'task_result') handlersRef.current.onTaskResult?.(data) + else if (ev.event === 'task_status') handlersRef.current.onTaskStatus?.(data) + } catch { + // ignore parse errors + } + }, + onerror() { + ctrl.abort() + }, + }) + + return () => ctrl.abort() + }, [taskId, accessToken]) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f399b11 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,32 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ConfigProvider } from 'antd' +import ruRU from 'antd/locale/ru_RU' +import 'dayjs/locale/ru' +import dayjs from 'dayjs' +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' + +dayjs.locale('ru') + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 10_000, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + +) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..7b9248e --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,49 @@ +import { LockOutlined, UserOutlined } from '@ant-design/icons' +import { Alert, Button, Card, Form, Input, Typography } from 'antd' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../api/client' +import { useAuthStore } from '../store/auth' + +export function LoginPage() { + const navigate = useNavigate() + const setTokens = useAuthStore((s) => s.setTokens) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const onFinish = async (values: { username: string; password: string }) => { + setLoading(true) + setError(null) + try { + const { data } = await api.post('/api/v1/auth/login', values) + setTokens(data.access_token, data.refresh_token, values.username) + navigate('/objects') + } catch { + setError('Неверный логин или пароль') + } finally { + setLoading(false) + } + } + + return ( +
+ + + Fleet Manager + + {error && } +
+ + } placeholder="Логин" size="large" /> + + + } placeholder="Пароль" size="large" /> + + +
+
+
+ ) +} diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx new file mode 100644 index 0000000..efe553d --- /dev/null +++ b/frontend/src/pages/ObjectDetail.tsx @@ -0,0 +1,328 @@ +import { + PlayCircleOutlined, + PlusOutlined, + SyncOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' +import { + Breadcrumb, + Button, + Col, + Descriptions, + Divider, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, + message, +} from 'antd' +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useCreateDevice, useDevices, useObject, useSyncObject, type DeviceItem } from '../api/objects' +import { useActions, useCreateTask } from '../api/tasks' +import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' +import dayjs from 'dayjs' + +export function ObjectDetailPage() { + const { id } = useParams<{ id: string }>() + const objId = Number(id) + const navigate = useNavigate() + + const { data: obj, isLoading: objLoading } = useObject(objId) + const { data: devices, isLoading: devLoading } = useDevices(objId) + const syncMutation = useSyncObject(objId) + const { data: actions } = useActions() + const createTask = useCreateTask() + + const createDevice = useCreateDevice(objId) + + const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null) + const [deviceModal, setDeviceModal] = useState(false) + const [form] = Form.useForm() + const [deviceForm] = Form.useForm() + const [selectedAction, setSelectedAction] = useState('') + + const currentAction = actions?.find((a) => a.name === selectedAction) + + const openTaskModal = (scope: object) => { + setTaskModal({ scope }) + setSelectedAction('') + form.resetFields() + } + + const submitTask = async () => { + const values = await form.validateFields() + const { action, ...params } = values + try { + const task = await createTask.mutateAsync({ + type: action, + target_scope: taskModal!.scope, + params, + }) + message.success(`Задача создана`) + setTaskModal(null) + navigate(`/tasks/${task.id}`) + } catch { + message.error('Ошибка создания задачи') + } + } + + const handleSync = async () => { + try { + const result = await syncMutation.mutateAsync() + message.success(`Синхронизировано: +${result.created} новых, ${result.updated} обновлено`) + } catch { + message.error('Ошибка синхронизации') + } + } + + const handleAddDevice = async () => { + const values = await deviceForm.validateFields() + const payload = Object.fromEntries( + Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined) + ) + try { + await createDevice.mutateAsync(payload as any) + message.success('Устройство добавлено') + setDeviceModal(false) + deviceForm.resetFields() + } catch { + message.error('Ошибка при добавлении устройства') + } + } + + if (objLoading) return + + const columns = [ + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + render: (ip: string) => {ip}, + }, + { + title: 'Hostname', + dataIndex: 'hostname', + key: 'hostname', + render: (h: string | null) => h ?? '—', + }, + { + title: 'Категория', + dataIndex: 'category', + key: 'category', + render: (c: string) => , + filters: ['raspberry', 'mikrotik', 'camera', 'embedded', 'server', 'other'].map((v) => ({ text: v, value: v })), + onFilter: (value: unknown, record: DeviceItem) => record.category === value, + }, + { + title: 'Роль', + dataIndex: 'role', + key: 'role', + render: (r: string) => {r}, + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + render: (s: string) => , + }, + { + title: 'Последний ping', + dataIndex: 'last_seen', + key: 'last_seen', + render: (ts: string | null, row: DeviceItem) => + ts ? ( + + {row.last_ping_ms}ms + + ) : ( + '—' + ), + }, + { + title: '', + key: 'actions', + render: (_: unknown, row: DeviceItem) => ( + + ), + }, + ] + + return ( +
+ navigate('/objects')}>Объекты }, + { title: obj?.name }, + ]} + /> + + + + + {obj?.server_ip ?? '—'} + {obj?.ssh_user ?? '—'} + {obj?.db_host ?? '—'} + + + + + + + + + + + + + + { setDeviceModal(false); deviceForm.resetFields() }} + onOk={handleAddDevice} + confirmLoading={createDevice.isPending} + okText="Добавить" + width={600} + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SSH (переопределить настройки объекта) + + + + + + + + + + + + + + + + + + + + + + setTaskModal(null)} + onOk={submitTask} + confirmLoading={createTask.isPending} + okText="Запустить" + > +
+ + + + ))} + +
+ + ) +} diff --git a/frontend/src/pages/Objects.tsx b/frontend/src/pages/Objects.tsx new file mode 100644 index 0000000..1d1c6ed --- /dev/null +++ b/frontend/src/pages/Objects.tsx @@ -0,0 +1,240 @@ +import { PlusOutlined } from '@ant-design/icons' +import { + Button, + Checkbox, + Col, + Divider, + Form, + Input, + InputNumber, + Modal, + Row, + Space, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useCreateObject, useObjects, type ObjectItem } from '../api/objects' + +export function ObjectsPage() { + const navigate = useNavigate() + const { data: objects, isLoading } = useObjects() + const createObject = useCreateObject() + const [modalOpen, setModalOpen] = useState(false) + const [form] = Form.useForm() + + const handleCreate = async () => { + const values = await form.validateFields() + // strip empty strings to undefined + const payload = Object.fromEntries( + Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined) + ) + try { + await createObject.mutateAsync(payload as any) + message.success('Объект создан') + setModalOpen(false) + form.resetFields() + } catch { + message.error('Ошибка при создании объекта') + } + } + + const columns = [ + { + title: 'Название', + dataIndex: 'name', + key: 'name', + render: (name: string, row: ObjectItem) => ( + + ), + }, + { + title: 'Сервер', + dataIndex: 'server_ip', + key: 'server_ip', + render: (ip: string | null) => + ip ?? , + }, + { + title: 'SSH', + dataIndex: 'ssh_user', + key: 'ssh_user', + render: (u: string | null) => + u ? {u} : , + }, + { + title: 'Теги', + dataIndex: 'tags', + key: 'tags', + render: (tags: ObjectItem['tags']) => + tags.map((t) => ( + + {t.name} + + )), + }, + { + title: 'Статус', + dataIndex: 'is_active', + key: 'is_active', + render: (active: boolean) => + active ? Активен : Неактивен, + }, + { + title: '', + key: 'actions', + render: (_: unknown, row: ObjectItem) => ( + + + + ), + }, + ] + + return ( +
+
+ + Объекты + + +
+ +
+ + { setModalOpen(false); form.resetFields() }} + onOk={handleCreate} + confirmLoading={createObject.isPending} + okText="Создать" + width={640} + > +
+ {/* ── Основное ── */} + +
+ + + + + + + + + + + + + + + + SSH (по умолчанию для устройств) + + + + + + + + + + + + + + + + + + + + + Подключение к БД объекта{' '} + + (для синхронизации устройств) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Подключаться через SSH-туннель + + + + + ) +} diff --git a/frontend/src/pages/TaskDetail.tsx b/frontend/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..6a110b5 --- /dev/null +++ b/frontend/src/pages/TaskDetail.tsx @@ -0,0 +1,184 @@ +import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons' +import { Breadcrumb, Card, Col, Descriptions, Row, Spin, Table, Tag, Typography } from 'antd' +import dayjs from 'dayjs' +import { useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useTask, type TaskResult } from '../api/tasks' +import { TaskStatusBadge } from '../components/StatusBadge' +import { useSSE } from '../hooks/useSSE' + +interface LogLine { + ts: string + message: string + level: string +} + +const LEVEL_COLOR: Record = { + info: '#52c41a', + warn: '#faad14', + error: '#ff4d4f', +} + +export function TaskDetailPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { data: task, isLoading } = useTask(id) + const [log, setLog] = useState([]) + const [deviceResults, setDeviceResults] = useState>({}) + const logEndRef = useRef(null) + + // Populate log from persisted events on load + useEffect(() => { + if (task?.events?.length) { + setLog(task.events.map((e) => ({ ts: e.created_at, message: e.message, level: e.level }))) + } + }, [task?.id]) // only on initial load + + // Live SSE updates + useSSE(id, { + onTaskEvent: (data) => + setLog((prev) => [...prev, { ts: data.ts, message: data.message, level: data.level }]), + onTaskResult: (data) => + setDeviceResults((prev) => ({ ...prev, [data.device_id]: { status: data.status, duration_ms: data.duration_ms } })), + }) + + // Auto-scroll log + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [log]) + + if (isLoading) return + + const scopeLabel = (() => { + const s = task?.target_scope as any + if (!s) return '—' + if (s.type === 'device') return `Устройство #${s.id}` + if (s.type === 'object') return `Объект #${s.id}` + if (s.type === 'category') return `Категория: ${s.value}` + if (s.type === 'role') return `Роль: ${s.value}` + return JSON.stringify(s) + })() + + const resultColumns = [ + { + title: 'Устройство', + dataIndex: 'device_id', + key: 'device_id', + render: (id: number) => `#${id}`, + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + render: (s: string) => + s === 'success' ? ( + } color="success">Успешно + ) : ( + } color="error">Ошибка + ), + }, + { + title: 'Время', + dataIndex: 'duration_ms', + key: 'duration_ms', + render: (ms: number | null) => (ms != null ? `${ms}ms` : '—'), + }, + { + title: 'Вывод', + dataIndex: 'stdout', + key: 'stdout', + ellipsis: true, + render: (s: string | null) => s ? {s.slice(0, 120)} : '—', + }, + ] + + const mergedResults = (task?.device_results ?? []).map((r: TaskResult) => ({ + ...r, + ...(deviceResults[r.device_id] ?? {}), + })) + + return ( +
+ navigate('/tasks')}>Задачи }, + { title: task?.type ?? id }, + ]} + /> + + +
+ + {task?.type} + + {task && } + + {scopeLabel} + + {task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')} + + {task?.result && ( + + {task.result.ok} успешно + {task.result.failed > 0 && {task.result.failed} ошибок} + всего {task.result.total} + + )} + + + + + + + +
+ {log.length === 0 ? ( + Ожидание событий... + ) : ( + log.map((line, i) => ( +
+ + {dayjs(line.ts).format('HH:mm:ss')} + + {line.message} +
+ )) + )} +
+
+ + + + {mergedResults.length > 0 && ( +
+ +
+ + + )} + + + ) +} diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx new file mode 100644 index 0000000..bec0dd4 --- /dev/null +++ b/frontend/src/pages/Tasks.tsx @@ -0,0 +1,69 @@ +import { Table, Typography } from 'antd' +import dayjs from 'dayjs' +import { useNavigate } from 'react-router-dom' +import { useTasks, type Task } from '../api/tasks' +import { TaskStatusBadge } from '../components/StatusBadge' + +export function TasksPage() { + const navigate = useNavigate() + const { data: tasks, isLoading } = useTasks() + + const columns = [ + { + title: 'Тип', + dataIndex: 'type', + key: 'type', + render: (t: string, row: Task) => ( + navigate(`/tasks/${row.id}`)}>{t} + ), + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + render: (s: string) => , + }, + { + title: 'Цель', + dataIndex: 'target_scope', + key: 'target_scope', + render: (s: Record) => { + if (s.type === 'device') return `Устройство #${s.id}` + if (s.type === 'object') return `Объект #${s.id}` + if (s.type === 'category') return `Категория: ${s.value}` + return JSON.stringify(s) + }, + }, + { + title: 'Итог', + dataIndex: 'result', + key: 'result', + render: (r: Task['result']) => + r ? `${r.ok}/${r.total}` : '—', + }, + { + title: 'Создана', + dataIndex: 'created_at', + key: 'created_at', + render: (ts: string) => dayjs(ts).format('DD.MM.YYYY HH:mm:ss'), + }, + ] + + return ( +
+ + Задачи + +
({ onClick: () => navigate(`/tasks/${row.id}`) })} + rowClassName={() => 'cursor-pointer'} + /> + + ) +} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000..5876ff9 --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface AuthState { + accessToken: string | null + refreshToken: string | null + username: string | null + setTokens: (access: string, refresh: string, username: string) => void + clearTokens: () => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + refreshToken: null, + username: null, + setTokens: (access, refresh, username) => + set({ accessToken: access, refreshToken: refresh, username }), + clearTokens: () => + set({ accessToken: null, refreshToken: null, username: null }), + }), + { name: 'fleet-auth' } + ) +) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..79a2287 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..caa367c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 3000, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, + }, +})