этап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.
|
Connects to a site object's external PostgreSQL database and syncs devices.
|
||||||
Uses short-lived psycopg3 connections (connect → query → disconnect).
|
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
|
from dataclasses import dataclass, field
|
||||||
|
|
@ -23,24 +30,23 @@ class SyncResult:
|
||||||
errors: list[str] = field(default_factory=list)
|
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] = {
|
_CATEGORY_MAP: dict[str, str] = {
|
||||||
|
"rack.lane": "embedded",
|
||||||
|
"rack.payment": "other",
|
||||||
|
"rack.server": "server",
|
||||||
|
"rack.camera": "camera",
|
||||||
"raspberry": "raspberry",
|
"raspberry": "raspberry",
|
||||||
"rpi": "raspberry",
|
"rpi": "raspberry",
|
||||||
"raspberrypi": "raspberry",
|
|
||||||
"raspberry_pi": "raspberry",
|
|
||||||
"mikrotik": "mikrotik",
|
"mikrotik": "mikrotik",
|
||||||
"routeros": "mikrotik",
|
|
||||||
"camera": "camera",
|
"camera": "camera",
|
||||||
"cam": "camera",
|
|
||||||
"ipcam": "camera",
|
|
||||||
"embedded": "embedded",
|
|
||||||
"server": "server",
|
"server": "server",
|
||||||
|
"embedded": "embedded",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _map_category(raw: str) -> str:
|
def _map_category(raw: str | None) -> str:
|
||||||
if raw is None:
|
if not raw:
|
||||||
return "other"
|
return "other"
|
||||||
return _CATEGORY_MAP.get(raw.strip().lower(), "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:
|
async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
|
||||||
result = 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")
|
result.errors.append("Object DB connection is not fully configured")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -69,51 +75,58 @@ async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with await psycopg.AsyncConnection.connect(dsn) as conn:
|
async with await psycopg.AsyncConnection.connect(dsn) as conn:
|
||||||
cols = [obj.db_col_ip]
|
# Pull id, name, type and host from the JSONB data column
|
||||||
if obj.db_col_type:
|
rows = await conn.execute(
|
||||||
cols.append(obj.db_col_type)
|
f'SELECT id, name, type, data->>\'host\' AS host FROM "{obj.db_table}"'
|
||||||
|
)
|
||||||
col_list = ", ".join(f'"{c}"' for c in cols)
|
|
||||||
rows = await conn.execute(f'SELECT {col_list} FROM "{obj.db_table}"')
|
|
||||||
remote_rows = await rows.fetchall()
|
remote_rows = await rows.fetchall()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result.errors.append(f"Cannot connect to object DB: {exc}")
|
result.errors.append(f"Cannot connect to object DB: {exc}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
for row in remote_rows:
|
for row in remote_rows:
|
||||||
ip = str(row[0]).strip() if row[0] else None
|
external_id, name, rack_type, host = row[0], row[1], row[2], row[3]
|
||||||
raw_type = str(row[1]).strip() if len(row) > 1 and row[1] else None
|
|
||||||
|
|
||||||
|
ip = str(host).strip() if host else None
|
||||||
if not ip:
|
if not ip:
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
continue
|
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(
|
existing = await db.execute(
|
||||||
select(Device).where(
|
select(Device).where(
|
||||||
Device.object_id == obj.id,
|
Device.object_id == obj.id,
|
||||||
Device.source == "auto_sync",
|
Device.external_id == str(external_id),
|
||||||
Device.ip == ip,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
device = existing.scalar_one_or_none()
|
device = existing.scalar_one_or_none()
|
||||||
|
|
||||||
if device is not None:
|
if device is not None:
|
||||||
changed = False
|
changed = False
|
||||||
|
if device.ip != ip:
|
||||||
|
device.ip = ip
|
||||||
|
changed = True
|
||||||
|
if device.hostname != hostname:
|
||||||
|
device.hostname = hostname
|
||||||
|
changed = True
|
||||||
if device.category != category:
|
if device.category != category:
|
||||||
device.category = category
|
device.category = category
|
||||||
changed = True
|
changed = True
|
||||||
result.updated += 1 if changed else 0
|
if changed:
|
||||||
result.skipped += 0 if changed else 1
|
result.updated += 1
|
||||||
|
else:
|
||||||
|
result.skipped += 1
|
||||||
else:
|
else:
|
||||||
db.add(Device(
|
db.add(Device(
|
||||||
object_id=obj.id,
|
object_id=obj.id,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
|
hostname=hostname,
|
||||||
category=category,
|
category=category,
|
||||||
source="auto_sync",
|
source="auto_sync",
|
||||||
external_id=ip,
|
external_id=str(external_id),
|
||||||
|
role=rack_type or "unknown",
|
||||||
))
|
))
|
||||||
result.created += 1
|
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
|
- ../backend:/app
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
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:
|
volumes:
|
||||||
postgres_data:
|
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