этап2-1
This commit is contained in:
parent
fd0dd9f59a
commit
68b9fa7417
21 changed files with 1500 additions and 26 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
6
docker/Dockerfile.frontend
Normal file
6
docker/Dockerfile.frontend
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
|
@ -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
12
frontend/index.html
Normal 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
29
frontend/package.json
Normal 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
35
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/api/client.ts
Normal file
49
frontend/src/api/client.ts
Normal 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
120
frontend/src/api/objects.ts
Normal 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
76
frontend/src/api/tasks.ts
Normal 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),
|
||||
})
|
||||
79
frontend/src/components/AppLayout.tsx
Normal file
79
frontend/src/components/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
frontend/src/components/StatusBadge.tsx
Normal file
40
frontend/src/components/StatusBadge.tsx
Normal 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>
|
||||
}
|
||||
40
frontend/src/hooks/useSSE.ts
Normal file
40
frontend/src/hooks/useSSE.ts
Normal 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
32
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
49
frontend/src/pages/Login.tsx
Normal file
49
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
328
frontend/src/pages/ObjectDetail.tsx
Normal file
328
frontend/src/pages/ObjectDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
frontend/src/pages/Objects.tsx
Normal file
240
frontend/src/pages/Objects.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
frontend/src/pages/TaskDetail.tsx
Normal file
184
frontend/src/pages/TaskDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
frontend/src/pages/Tasks.tsx
Normal file
69
frontend/src/pages/Tasks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/store/auth.ts
Normal file
25
frontend/src/store/auth.ts
Normal 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
19
frontend/tsconfig.json
Normal 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
16
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue