этап2-1

This commit is contained in:
dv 2026-04-07 00:52:45 +03:00
parent fd0dd9f59a
commit 68b9fa7417
21 changed files with 1500 additions and 26 deletions

View file

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

View file

@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

View file

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

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fleet Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
frontend/package.json Normal file
View file

@ -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"
}
}

35
frontend/src/App.tsx Normal file
View file

@ -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}</> : <Navigate to="/login" replace />
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/objects" replace />} />
<Route path="objects" element={<ObjectsPage />} />
<Route path="objects/:id" element={<ObjectDetailPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:id" element={<TaskDetailPage />} />
</Route>
</Routes>
)
}

View file

@ -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<string> | 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)
}
)

120
frontend/src/api/objects.ts Normal file
View file

@ -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<ObjectItem[]>('/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<ObjectItem>(`/api/v1/objects/${id}`).then((r) => r.data),
})
export const useDevices = (objId: number) =>
useQuery({
queryKey: ['devices', objId],
queryFn: () =>
api.get<DeviceItem[]>(`/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<ObjectItem>('/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<DeviceItem>(`/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<SyncResult>(`/api/v1/objects/${objId}/sync`).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}

76
frontend/src/api/tasks.ts Normal file
View file

@ -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<string, unknown>
params: Record<string, unknown>
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<Task[]>('/api/v1/tasks').then((r) => r.data),
refetchInterval: 5000,
})
export const useTask = (id: string | undefined) =>
useQuery({
queryKey: ['tasks', id],
queryFn: () => api.get<TaskDetail>(`/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<ActionInfo[]>('/api/v1/tasks/actions').then((r) => r.data),
})
export const useCreateTask = () =>
useMutation({
mutationFn: (body: { type: string; target_scope: object; params: object }) =>
api.post<Task>('/api/v1/tasks', body).then((r) => r.data),
})

View file

@ -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 (
<Layout style={{ minHeight: '100vh' }}>
<Sider theme="dark" width={220}>
<div style={{ padding: '16px 24px', color: '#fff' }}>
<Typography.Text strong style={{ color: '#fff', fontSize: 16 }}>
Fleet Manager
</Typography.Text>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
items={[
{
key: 'objects',
icon: <ApartmentOutlined />,
label: 'Объекты',
onClick: () => navigate('/objects'),
},
{
key: 'tasks',
icon: <ThunderboltOutlined />,
label: 'Задачи',
onClick: () => navigate('/tasks'),
},
]}
/>
<div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, padding: '0 24px' }}>
<Menu
theme="dark"
mode="inline"
selectable={false}
items={[
{
key: 'logout',
icon: <LogoutOutlined />,
label: username,
onClick: handleLogout,
},
]}
/>
</div>
</Sider>
<Layout>
<Content style={{ padding: 24, background: '#f5f5f5', minHeight: '100vh' }}>
<Outlet />
</Content>
</Layout>
</Layout>
)
}

View file

@ -0,0 +1,40 @@
import { Badge, Tag } from 'antd'
const DEVICE_STATUS: Record<string, { color: string; text: string }> = {
online: { color: 'success', text: 'Online' },
offline: { color: 'error', text: 'Offline' },
unknown: { color: 'default', text: 'Unknown' },
}
const TASK_STATUS: Record<string, { color: string; text: string }> = {
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<string, string> = {
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 <Badge status={cfg.color as any} text={cfg.text} />
}
export function TaskStatusBadge({ status }: { status: string }) {
const cfg = TASK_STATUS[status] ?? { color: 'default', text: status }
return <Badge status={cfg.color as any} text={cfg.text} />
}
export function CategoryTag({ category }: { category: string }) {
return <Tag color={CATEGORY_COLOR[category] ?? 'default'}>{category}</Tag>
}

View file

@ -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])
}

32
frontend/src/main.tsx Normal file
View file

@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={ruRU}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>
)

View file

@ -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<string | null>(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 (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: '#f0f2f5' }}>
<Card style={{ width: 360 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
Fleet Manager
</Typography.Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form onFinish={onFinish} layout="vertical">
<Form.Item name="username" rules={[{ required: true, message: 'Введите логин' }]}>
<Input prefix={<UserOutlined />} placeholder="Логин" size="large" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: 'Введите пароль' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="Пароль" size="large" />
</Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
Войти
</Button>
</Form>
</Card>
</div>
)
}

View file

@ -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<string>('')
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 <Spin />
const columns = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <code>{ip}</code>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (h: string | null) => h ?? '—',
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (c: string) => <CategoryTag category={c} />,
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) => <Tag>{r}</Tag>,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />,
},
{
title: 'Последний ping',
dataIndex: 'last_seen',
key: 'last_seen',
render: (ts: string | null, row: DeviceItem) =>
ts ? (
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
<span>{row.last_ping_ms}ms</span>
</Tooltip>
) : (
'—'
),
},
{
title: '',
key: 'actions',
render: (_: unknown, row: DeviceItem) => (
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => openTaskModal({ type: 'device', id: row.id })}
>
Запустить
</Button>
),
},
]
return (
<div>
<Breadcrumb
style={{ marginBottom: 16 }}
items={[
{ title: <a onClick={() => navigate('/objects')}>Объекты</a> },
{ title: obj?.name },
]}
/>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col flex="auto">
<Descriptions size="small" bordered column={3}>
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
<Descriptions.Item label="БД">{obj?.db_host ?? '—'}</Descriptions.Item>
</Descriptions>
</Col>
<Col>
<Space>
<Button
icon={<SyncOutlined />}
loading={syncMutation.isPending}
onClick={handleSync}
>
Синхронизировать из БД
</Button>
<Button
icon={<PlusOutlined />}
onClick={() => setDeviceModal(true)}
>
Добавить устройство
</Button>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => openTaskModal({ type: 'object', id: objId })}
>
Задача на весь объект
</Button>
</Space>
</Col>
</Row>
<Table
dataSource={devices ?? []}
columns={columns}
rowKey="id"
loading={devLoading}
size="middle"
pagination={{ pageSize: 50 }}
/>
<Modal
title="Добавить устройство"
open={deviceModal}
onCancel={() => { setDeviceModal(false); deviceForm.resetFields() }}
onOk={handleAddDevice}
confirmLoading={createDevice.isPending}
okText="Добавить"
width={600}
>
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Input placeholder="192.168.1.100" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="hostname" label="Hostname">
<Input placeholder="camera-01" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="Категория" rules={[{ required: true }]}>
<Select
placeholder="Выберите категорию"
options={[
{ value: 'raspberry', label: 'Raspberry Pi' },
{ value: 'mikrotik', label: 'Mikrotik' },
{ value: 'camera', label: 'Камера' },
{ value: 'server', label: 'Сервер' },
{ value: 'embedded', label: 'Embedded' },
{ value: 'other', label: 'Другое' },
]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="role" label="Роль" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Input placeholder="entry / parking / barrier" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="model" label="Модель">
<Input placeholder="DS-2CD2143G2-I" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="mac" label="MAC">
<Input placeholder="AA:BB:CC:DD:EE:FF" />
</Form.Item>
</Col>
</Row>
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="ssh_user" label="Логин">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password" label="Пароль">
<Input.Password placeholder="••••••••" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="ssh_port" label="Порт">
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Modal
title="Создать задачу"
open={!!taskModal}
onCancel={() => setTaskModal(null)}
onOk={submitTask}
confirmLoading={createTask.isPending}
okText="Запустить"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="action" label="Действие" rules={[{ required: true }]}>
<Select
placeholder="Выберите действие"
options={actions?.map((a) => ({ value: a.name, label: a.display_name }))}
onChange={setSelectedAction}
/>
</Form.Item>
{currentAction?.params_schema.map((p) => (
<Form.Item
key={p.name}
name={p.name}
label={p.label}
rules={p.required ? [{ required: true }] : []}
>
<Input />
</Form.Item>
))}
</Form>
</Modal>
</div>
)
}

View file

@ -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) => (
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/objects/${row.id}`)}>
{name}
</Button>
),
},
{
title: 'Сервер',
dataIndex: 'server_ip',
key: 'server_ip',
render: (ip: string | null) =>
ip ?? <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'SSH',
dataIndex: 'ssh_user',
key: 'ssh_user',
render: (u: string | null) =>
u ? <Tag>{u}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Теги',
dataIndex: 'tags',
key: 'tags',
render: (tags: ObjectItem['tags']) =>
tags.map((t) => (
<Tag key={t.id} color={t.color}>
{t.name}
</Tag>
)),
},
{
title: 'Статус',
dataIndex: 'is_active',
key: 'is_active',
render: (active: boolean) =>
active ? <Tag color="green">Активен</Tag> : <Tag color="default">Неактивен</Tag>,
},
{
title: '',
key: 'actions',
render: (_: unknown, row: ObjectItem) => (
<Space>
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
Устройства
</Button>
</Space>
),
},
]
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
Объекты
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
Добавить
</Button>
</div>
<Table
dataSource={objects ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 20 }}
/>
<Modal
title="Новый объект"
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleCreate}
confirmLoading={createObject.isPending}
okText="Создать"
width={640}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
{/* ── Основное ── */}
<Row gutter={16}>
<Col span={14}>
<Form.Item name="name" label="Название" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Input placeholder="Анапа, Парковка ТЦ Мира" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="server_ip" label="IP сервера объекта">
<Input placeholder="10.23.0.201" />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Описание">
<Input.TextArea rows={2} />
</Form.Item>
<Divider orientation="left" plain>SSH (по умолчанию для устройств)</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="ssh_user" label="Логин">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password" label="Пароль">
<Input.Password placeholder="••••••••" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="ssh_port" label="Порт" initialValue={22}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
</Row>
<Divider orientation="left" plain>
Подключение к БД объекта{' '}
<Typography.Text type="secondary" style={{ fontWeight: 400 }}>
(для синхронизации устройств)
</Typography.Text>
</Divider>
<Row gutter={16}>
<Col span={14}>
<Form.Item name="db_host" label="Хост БД">
<Input placeholder="10.23.0.202" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="db_port" label="Порт" initialValue={5432}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_name" label="База данных">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_user" label="Пользователь">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_password" label="Пароль">
<Input.Password placeholder="••••••••" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_table" label="Таблица">
<Input placeholder="devices" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_col_ip" label="Колонка IP">
<Input placeholder="ip_address" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_col_type" label="Колонка типа">
<Input placeholder="device_type" />
</Form.Item>
</Col>
</Row>
<Form.Item name="db_via_tunnel" valuePropName="checked">
<Checkbox>Подключаться через SSH-туннель</Checkbox>
</Form.Item>
</Form>
</Modal>
</div>
)
}

View file

@ -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<string, string> = {
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<LogLine[]>([])
const [deviceResults, setDeviceResults] = useState<Record<number, { status: string; duration_ms: number }>>({})
const logEndRef = useRef<HTMLDivElement>(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 <Spin />
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' ? (
<Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
),
},
{
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 ? <code style={{ fontSize: 12 }}>{s.slice(0, 120)}</code> : '—',
},
]
const mergedResults = (task?.device_results ?? []).map((r: TaskResult) => ({
...r,
...(deviceResults[r.device_id] ?? {}),
}))
return (
<div>
<Breadcrumb
style={{ marginBottom: 16 }}
items={[
{ title: <a onClick={() => navigate('/tasks')}>Задачи</a> },
{ title: task?.type ?? id },
]}
/>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Descriptions size="small" bordered column={4}>
<Descriptions.Item label="Тип">{task?.type}</Descriptions.Item>
<Descriptions.Item label="Статус">
{task && <TaskStatusBadge status={task.status} />}
</Descriptions.Item>
<Descriptions.Item label="Цель">{scopeLabel}</Descriptions.Item>
<Descriptions.Item label="Создана">
{task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')}
</Descriptions.Item>
{task?.result && (
<Descriptions.Item label="Итог">
<Tag color="green">{task.result.ok} успешно</Tag>
{task.result.failed > 0 && <Tag color="red">{task.result.failed} ошибок</Tag>}
<Tag>всего {task.result.total}</Tag>
</Descriptions.Item>
)}
</Descriptions>
</Col>
</Row>
<Row gutter={16}>
<Col span={24}>
<Card
title="Лог выполнения"
size="small"
bodyStyle={{ padding: 0 }}
style={{ marginBottom: 16 }}
>
<div
style={{
background: '#1e1e1e',
fontFamily: 'monospace',
fontSize: 13,
padding: '12px 16px',
maxHeight: 400,
overflowY: 'auto',
minHeight: 120,
}}
>
{log.length === 0 ? (
<Typography.Text style={{ color: '#666' }}>Ожидание событий...</Typography.Text>
) : (
log.map((line, i) => (
<div key={i} style={{ color: LEVEL_COLOR[line.level] ?? '#ccc', marginBottom: 2 }}>
<span style={{ color: '#666', marginRight: 8 }}>
{dayjs(line.ts).format('HH:mm:ss')}
</span>
{line.message}
</div>
))
)}
<div ref={logEndRef} />
</div>
</Card>
</Col>
{mergedResults.length > 0 && (
<Col span={24}>
<Card title="Результаты по устройствам" size="small">
<Table
dataSource={mergedResults}
columns={resultColumns}
rowKey="id"
size="small"
pagination={false}
/>
</Card>
</Col>
)}
</Row>
</div>
)
}

View file

@ -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) => (
<a onClick={() => navigate(`/tasks/${row.id}`)}>{t}</a>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
render: (s: string) => <TaskStatusBadge status={s} />,
},
{
title: 'Цель',
dataIndex: 'target_scope',
key: 'target_scope',
render: (s: Record<string, unknown>) => {
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 (
<div>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
Задачи
</Typography.Title>
<Table
dataSource={tasks ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 30 }}
onRow={(row) => ({ onClick: () => navigate(`/tasks/${row.id}`) })}
rowClassName={() => 'cursor-pointer'}
/>
</div>
)
}

View file

@ -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<AuthState>()(
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' }
)
)

19
frontend/tsconfig.json Normal file
View file

@ -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"]
}

16
frontend/vite.config.ts Normal file
View file

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