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 (
+
+ )
+}
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) => (
+ }
+ onClick={() => openTaskModal({ type: 'device', id: row.id })}
+ >
+ Запустить
+
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
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 (
+
+ )
+}
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,
+ },
+ },
+ },
+})