This commit is contained in:
dv 2026-04-07 17:14:19 +03:00
parent 6b36485718
commit 402d3d6a21
20 changed files with 1047 additions and 31 deletions

View file

@ -0,0 +1,29 @@
"""0007 plugins registry
Revision ID: 0007
Revises: 0006
Create Date: 2026-04-07
"""
from alembic import op
import sqlalchemy as sa
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"plugins",
sa.Column("name", sa.String(100), primary_key=True),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("plugins")

View file

@ -6,14 +6,14 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.plugins.loader import load_plugins from app.plugins.loader import load_plugins
from app.routers import alerts, auth, devices, events, objects, racks, snapshots, tags, tasks, zones from app.routers import admin, alerts, auth, devices, events, objects, racks, snapshots, tags, tasks, zones
from app.workers.monitor import start_monitor, stop_monitor from app.workers.monitor import start_monitor, stop_monitor
from app.workers.startup import recover_stale_tasks from app.workers.startup import recover_stale_tasks
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
load_plugins() await load_plugins()
await recover_stale_tasks() await recover_stale_tasks()
start_monitor() start_monitor()
yield yield
@ -45,6 +45,7 @@ def create_app() -> FastAPI:
app.include_router(racks.router) app.include_router(racks.router)
app.include_router(zones.router) app.include_router(zones.router)
app.include_router(tags.router) app.include_router(tags.router)
app.include_router(admin.router)
app.include_router(alerts.router) app.include_router(alerts.router)
app.include_router(snapshots.router) app.include_router(snapshots.router)
app.include_router(tasks.router) app.include_router(tasks.router)

View file

@ -0,0 +1,19 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class Plugin(Base):
"""Registry of known action plugins with enable/disable control."""
__tablename__ = "plugins"
name: Mapped[str] = mapped_column(String(100), primary_key=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean(), nullable=False, default=True)
registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View file

@ -1,10 +1,23 @@
""" """
PluginLoader discovers and registers action plugins at startup. PluginLoader registers action plugins at startup and syncs them with the DB.
Étape 3: builtins include SSH file snapshots, Mikrotik, and Camera plugins. Startup flow:
Étape 5: will scan plugins/custom/ and cross-reference with the plugins DB table. 1. For each builtin action, INSERT into `plugins` table (skip if already exists).
This preserves the `enabled` flag set by admins.
2. Load the list of enabled plugin names from DB.
3. Register only enabled actions in the registry.
Hot enable/disable (via Admin API):
- Disable: set DB.enabled=False, call action_registry.unregister(name)
- Enable: set DB.enabled=True, call action_registry.register(BUILTIN_ACTIONS[name])
""" """
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.models.plugin import Plugin
from app.plugins.base import BaseAction from app.plugins.base import BaseAction
from app.plugins.builtin.camera.get_status import CameraGetStatusAction from app.plugins.builtin.camera.get_status import CameraGetStatusAction
from app.plugins.builtin.camera.set_ntp import CameraSetNTPAction from app.plugins.builtin.camera.set_ntp import CameraSetNTPAction
@ -16,24 +29,52 @@ from app.plugins.builtin.ssh.get_file import GetFileAction
from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction
from app.plugins.builtin.ssh.ssh_command import SSHCommandAction from app.plugins.builtin.ssh.ssh_command import SSHCommandAction
from app.plugins.registry import action_registry from app.plugins.registry import action_registry
from app.services.database import AsyncSessionLocal
_BUILTIN_ACTIONS: list[BaseAction] = [ # Public dict — used by Admin router for hot enable/disable
# Universal BUILTIN_ACTIONS: dict[str, BaseAction] = {
PingAction(), a.name: a
# SSH / Linux for a in [
SSHCommandAction(), PingAction(),
GetTimedatectlAction(), SSHCommandAction(),
GetFileAction(), GetTimedatectlAction(),
# Mikrotik (RouterOS via SSH) GetFileAction(),
MikrotikGetConfigAction(), MikrotikGetConfigAction(),
MikrotikGetNTPAction(), MikrotikGetNTPAction(),
MikrotikSetNTPAction(), MikrotikSetNTPAction(),
# Cameras (Hikvision ISAPI) CameraGetStatusAction(),
CameraGetStatusAction(), CameraSetNTPAction(),
CameraSetNTPAction(), ]
] }
def load_plugins() -> None: async def load_plugins() -> None:
for action in _BUILTIN_ACTIONS: now = datetime.now(timezone.utc)
action_registry.register(action)
async with AsyncSessionLocal() as db:
async with db.begin():
for action in BUILTIN_ACTIONS.values():
stmt = (
pg_insert(Plugin)
.values(
name=action.name,
display_name=action.display_name,
description=(action.__class__.__doc__ or "").strip() or None,
enabled=True,
registered_at=now,
)
.on_conflict_do_nothing(index_elements=["name"])
)
await db.execute(stmt)
# Load enabled plugins from DB
async with AsyncSessionLocal() as db:
result = await db.execute(select(Plugin.name).where(Plugin.enabled == True))
enabled_names = {row[0] for row in result.all()}
# Register only enabled actions
for name, action in BUILTIN_ACTIONS.items():
if name in enabled_names:
action_registry.register(action)
print(f"[plugins] Registered {len(enabled_names)}/{len(BUILTIN_ACTIONS)} plugin(s)")

View file

@ -14,6 +14,9 @@ class ActionRegistry:
def all(self) -> list[BaseAction]: def all(self) -> list[BaseAction]:
return list(self._actions.values()) return list(self._actions.values())
def unregister(self, name: str) -> None:
self._actions.pop(name, None)
def for_category(self, category: str) -> list[BaseAction]: def for_category(self, category: str) -> list[BaseAction]:
return [a for a in self._actions.values() if a.is_compatible(category)] return [a for a in self._actions.values() if a.is_compatible(category)]

View file

@ -0,0 +1,160 @@
"""
Admin router plugin management and user CRUD.
All endpoints require admin role.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_admin
from app.models.plugin import Plugin
from app.models.user import User
from app.plugins.loader import BUILTIN_ACTIONS
from app.plugins.registry import action_registry
from app.schemas.admin import PluginRead, PluginUpdate, UserCreate, UserRead, UserUpdate
from app.services.auth import hash_password
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# ── Plugins ────────────────────────────────────────────────────────────────────
@router.get("/plugins", response_model=list[PluginRead])
async def list_plugins(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
result = await db.execute(select(Plugin).order_by(Plugin.name))
plugins = result.scalars().all()
out = []
for p in plugins:
action = BUILTIN_ACTIONS.get(p.name)
out.append(
PluginRead(
name=p.name,
display_name=p.display_name,
description=p.description,
enabled=p.enabled,
registered_at=p.registered_at,
compatible_categories=action.compatible_categories if action else [],
)
)
return out
@router.patch("/plugins/{name}", response_model=PluginRead)
async def update_plugin(
name: str,
body: PluginUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
plugin = await db.get(Plugin, name)
if plugin is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
plugin.enabled = body.enabled
# Hot-reload the registry immediately
if body.enabled:
action = BUILTIN_ACTIONS.get(name)
if action:
action_registry.register(action)
else:
action_registry.unregister(name)
await db.flush()
action = BUILTIN_ACTIONS.get(name)
return PluginRead(
name=plugin.name,
display_name=plugin.display_name,
description=plugin.description,
enabled=plugin.enabled,
registered_at=plugin.registered_at,
compatible_categories=action.compatible_categories if action else [],
)
# ── Users ──────────────────────────────────────────────────────────────────────
@router.get("/users", response_model=list[UserRead])
async def list_users(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
result = await db.execute(select(User).order_by(User.username))
return result.scalars().all()
@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
# Check username uniqueness
existing = await db.execute(select(User).where(User.username == body.username))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{body.username}' is already taken",
)
user = User(
username=body.username,
email=body.email,
full_name=body.full_name,
password_hash=hash_password(body.password),
role=body.role,
is_active=True,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
@router.patch("/users/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(require_admin),
):
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if body.email is not None:
user.email = body.email
if body.full_name is not None:
user.full_name = body.full_name
if body.role is not None:
user.role = body.role
if body.is_active is not None:
user.is_active = body.is_active
if body.password:
user.password_hash = hash_password(body.password)
await db.flush()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(require_admin),
):
if user_id == current_admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account",
)
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await db.delete(user)

View file

@ -21,7 +21,12 @@ async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends
ip_address=request.client.host if request.client else None, ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"), user_agent=request.headers.get("User-Agent"),
) )
return TokenResponse(access_token=access_token, refresh_token=refresh_token) return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
username=user.username,
role=user.role,
)
@router.post("/refresh", response_model=TokenResponse) @router.post("/refresh", response_model=TokenResponse)
@ -36,7 +41,12 @@ async def refresh(body: RefreshRequest, request: Request, db: AsyncSession = Dep
user, new_refresh_token = result user, new_refresh_token = result
access_token = auth_service.create_access_token(user.id) access_token = auth_service.create_access_token(user.id)
return TokenResponse(access_token=access_token, refresh_token=new_refresh_token) return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
username=user.username,
role=user.role,
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)

View file

@ -8,9 +8,11 @@ from app.models.device import Device
from app.models.object import Object from app.models.object import Object
from app.models.tag import Tag from app.models.tag import Tag
from app.models.user import User from app.models.user import User
from app.schemas.admin import SheetsSyncRequest
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate, SyncResult from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate, SyncResult
from app.services import crypto from app.services import crypto
from app.services.object_db import sync_from_object_db from app.services.object_db import sync_from_object_db
from app.services.sheets import sync_from_sheets
router = APIRouter(prefix="/api/v1/objects", tags=["objects"]) router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
@ -136,3 +138,28 @@ async def sync_devices(
skipped=result.skipped, skipped=result.skipped,
errors=result.errors, errors=result.errors,
) )
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
async def sync_devices_from_sheets(
obj_id: int,
body: SheetsSyncRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _load_object(db, obj_id)
try:
result = await sync_from_sheets(
db,
object_id=obj_id,
spreadsheet_id=body.spreadsheet_id,
sheet_name=body.sheet_name,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return SyncResult(
created=result.created,
updated=result.updated,
skipped=result.skipped,
errors=result.errors,
)

View file

@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
# ── Plugins ────────────────────────────────────────────────────────────────────
class PluginRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
display_name: str
description: Optional[str]
enabled: bool
registered_at: datetime
compatible_categories: list[str] = []
class PluginUpdate(BaseModel):
enabled: bool
# ── Users ──────────────────────────────────────────────────────────────────────
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: Optional[str]
full_name: Optional[str]
role: str
is_active: bool
created_at: datetime
class UserCreate(BaseModel):
username: str
password: str
email: Optional[str] = None
full_name: Optional[str] = None
role: str = "operator" # "admin" | "operator"
class UserUpdate(BaseModel):
email: Optional[str] = None
full_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None # if set, hashes and updates
# ── Google Sheets sync ─────────────────────────────────────────────────────────
class SheetsSyncRequest(BaseModel):
spreadsheet_id: str
sheet_name: Optional[str] = None # defaults to first sheet

View file

@ -10,6 +10,8 @@ class TokenResponse(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
username: str = ""
role: str = "operator"
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):

View file

@ -0,0 +1,165 @@
"""
Google Sheets sync service.
Reads device rows from a Google Spreadsheet and upserts them into the devices table.
Column mapping (case-insensitive): ip, hostname, category, role, model, mac, notes.
Prerequisites:
- Set GOOGLE_SERVICE_ACCOUNT_JSON env var with a service account JSON key.
- Share the target spreadsheet with the service account email (read-only is enough).
gspread is synchronous; calls are wrapped in run_in_executor to avoid blocking.
"""
import asyncio
import functools
import json
import os
from dataclasses import dataclass, field
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.device import Device
try:
import gspread
from google.oauth2.service_account import Credentials as ServiceAccountCredentials
_GSPREAD_AVAILABLE = True
except ImportError:
_GSPREAD_AVAILABLE = False
_SCOPES = [
"https://spreadsheets.google.com/feeds",
"https://www.googleapis.com/auth/drive.readonly",
]
# Supported column names (lowercase) → Device field
_COLUMN_MAP = {
"ip": "ip",
"hostname": "hostname",
"category": "category",
"role": "role",
"model": "model",
"mac": "mac",
"notes": "notes",
}
_VALID_CATEGORIES = {"raspberry", "mikrotik", "camera", "embedded", "server", "other"}
@dataclass
class SheetsSyncResult:
created: int = 0
updated: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
def _get_gspread_client():
if not _GSPREAD_AVAILABLE:
raise ValueError(
"gspread is not installed. Add 'gspread' to project dependencies."
)
creds_raw = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON")
if not creds_raw:
raise ValueError(
"GOOGLE_SERVICE_ACCOUNT_JSON environment variable is not set. "
"Provide a service account JSON key to enable Google Sheets sync."
)
creds_data = json.loads(creds_raw)
creds = ServiceAccountCredentials.from_service_account_info(creds_data, scopes=_SCOPES)
return gspread.authorize(creds)
def _fetch_rows_sync(spreadsheet_id: str, sheet_name: Optional[str]) -> list[dict]:
"""Synchronous fetch — runs in executor."""
client = _get_gspread_client()
sh = client.open_by_key(spreadsheet_id)
ws = sh.worksheet(sheet_name) if sheet_name else sh.sheet1
return ws.get_all_records() # list of dicts keyed by header row
async def fetch_rows(spreadsheet_id: str, sheet_name: Optional[str]) -> list[dict]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
functools.partial(_fetch_rows_sync, spreadsheet_id, sheet_name),
)
def _normalize_row(raw: dict) -> dict:
"""Lowercase keys, strip values."""
return {k.lower().strip(): str(v).strip() for k, v in raw.items() if v != ""}
async def sync_from_sheets(
db: AsyncSession,
object_id: int,
spreadsheet_id: str,
sheet_name: Optional[str] = None,
) -> SheetsSyncResult:
result = SheetsSyncResult()
# 1. Fetch rows from Google Sheets (blocking I/O in executor)
try:
raw_rows = await fetch_rows(spreadsheet_id, sheet_name)
except Exception as exc:
result.errors.append(f"Failed to read spreadsheet: {exc}")
return result
# 2. Load existing devices for this object (keyed by ip)
existing_result = await db.execute(
select(Device).where(Device.object_id == object_id)
)
existing: dict[str, Device] = {d.ip: d for d in existing_result.scalars().all()}
for i, raw in enumerate(raw_rows, start=2): # row 2 = first data row (after header)
row = _normalize_row(raw)
ip = row.get("ip", "").strip()
if not ip:
result.errors.append(f"Row {i}: missing 'ip' column — skipped")
result.skipped += 1
continue
category = row.get("category", "other")
if category not in _VALID_CATEGORIES:
category = "other"
if ip in existing:
dev = existing[ip]
changed = False
for col, field_name in _COLUMN_MAP.items():
if col in row and col != "ip":
new_val = row[col] or None
if getattr(dev, field_name) != new_val:
setattr(dev, field_name, new_val)
changed = True
dev.source = "sheets_sync"
if changed:
result.updated += 1
else:
result.skipped += 1
else:
dev = Device(
object_id=object_id,
ip=ip,
hostname=row.get("hostname") or None,
category=category,
role=row.get("role", "other"),
model=row.get("model") or None,
mac=row.get("mac") or None,
notes=row.get("notes") or None,
source="sheets_sync",
status="unknown",
)
db.add(dev)
existing[ip] = dev
result.created += 1
return result

View file

@ -18,6 +18,9 @@ dependencies = [
"python-multipart>=0.0.20", "python-multipart>=0.0.20",
"httpx>=0.28", "httpx>=0.28",
"asyncssh>=2.18", "asyncssh>=2.18",
# Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var)
"gspread>=6.0",
"google-auth>=2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { AppLayout } from './components/AppLayout' import { AppLayout } from './components/AppLayout'
import { AdminPage } from './pages/Admin'
import { AlertsPage } from './pages/Alerts' import { AlertsPage } from './pages/Alerts'
import { LoginPage } from './pages/Login' import { LoginPage } from './pages/Login'
import { ObjectDetailPage } from './pages/ObjectDetail' import { ObjectDetailPage } from './pages/ObjectDetail'
@ -27,6 +28,7 @@ export default function App() {
} }
> >
<Route index element={<Navigate to="/objects" replace />} /> <Route index element={<Navigate to="/objects" replace />} />
<Route path="admin" element={<AdminPage />} />
<Route path="alerts" element={<AlertsPage />} /> <Route path="alerts" element={<AlertsPage />} />
<Route path="objects" element={<ObjectsPage />} /> <Route path="objects" element={<ObjectsPage />} />
<Route path="objects/:id" element={<ObjectDetailPage />} /> <Route path="objects/:id" element={<ObjectDetailPage />} />

88
frontend/src/api/admin.ts Normal file
View file

@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
// ── Plugins ───────────────────────────────────────────────────────────────────
export interface PluginItem {
name: string
display_name: string
description: string | null
enabled: boolean
registered_at: string
compatible_categories: string[]
}
export const usePlugins = () =>
useQuery({
queryKey: ['admin-plugins'],
queryFn: () => api.get<PluginItem[]>('/api/v1/admin/plugins').then((r) => r.data),
})
export const useUpdatePlugin = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
api.patch<PluginItem>(`/api/v1/admin/plugins/${name}`, { enabled }).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-plugins'] }),
})
}
// ── Users ─────────────────────────────────────────────────────────────────────
export interface UserItem {
id: number
username: string
email: string | null
full_name: string | null
role: string
is_active: boolean
created_at: string
}
export interface UserCreatePayload {
username: string
password: string
email?: string
full_name?: string
role?: string
}
export interface UserUpdatePayload {
email?: string
full_name?: string
role?: string
is_active?: boolean
password?: string
}
export const useAdminUsers = () =>
useQuery({
queryKey: ['admin-users'],
queryFn: () => api.get<UserItem[]>('/api/v1/admin/users').then((r) => r.data),
})
export const useCreateAdminUser = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: UserCreatePayload) =>
api.post<UserItem>('/api/v1/admin/users', body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }),
})
}
export const useUpdateAdminUser = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UserUpdatePayload }) =>
api.patch<UserItem>(`/api/v1/admin/users/${id}`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }),
})
}
export const useDeleteAdminUser = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/admin/users/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }),
})
}

View file

@ -194,3 +194,17 @@ export const useSyncObject = (objId: number) => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
}) })
} }
export interface SheetsSyncPayload {
spreadsheet_id: string
sheet_name?: string
}
export const useSyncSheets = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: SheetsSyncPayload) =>
api.post<SyncResult>(`/api/v1/objects/${objId}/sync/sheets`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}

View file

@ -2,6 +2,7 @@ import {
ApartmentOutlined, ApartmentOutlined,
BellOutlined, BellOutlined,
LogoutOutlined, LogoutOutlined,
SettingOutlined,
ThunderboltOutlined, ThunderboltOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Badge, Layout, Menu, Typography } from 'antd' import { Badge, Layout, Menu, Typography } from 'antd'
@ -18,7 +19,7 @@ export function AppLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const qc = useQueryClient() const qc = useQueryClient()
const { username, refreshToken, clearTokens } = useAuthStore() const { username, role, refreshToken, clearTokens } = useAuthStore()
// Open alert count for sidebar badge — refresh every 60s // Open alert count for sidebar badge — refresh every 60s
const { data: openAlerts } = useAlerts({ const { data: openAlerts } = useAlerts({
@ -42,6 +43,8 @@ export function AppLayout() {
? 'tasks' ? 'tasks'
: location.pathname.startsWith('/alerts') : location.pathname.startsWith('/alerts')
? 'alerts' ? 'alerts'
: location.pathname.startsWith('/admin')
? 'admin'
: 'objects' : 'objects'
const handleLogout = async () => { const handleLogout = async () => {
@ -91,6 +94,16 @@ export function AppLayout() {
label: 'Задачи', label: 'Задачи',
onClick: () => navigate('/tasks'), onClick: () => navigate('/tasks'),
}, },
...(role === 'admin'
? [
{
key: 'admin',
icon: <SettingOutlined />,
label: 'Администрирование',
onClick: () => navigate('/admin'),
},
]
: []),
]} ]}
/> />
<div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, padding: '0 24px' }}> <div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, padding: '0 24px' }}>

View file

@ -0,0 +1,323 @@
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
UserOutlined,
ApiOutlined,
} from '@ant-design/icons'
import {
Badge,
Breadcrumb,
Button,
Col,
Divider,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Space,
Switch,
Table,
Tag,
Tabs,
Typography,
message,
} from 'antd'
import { useState } from 'react'
import {
useAdminUsers,
useCreateAdminUser,
useDeleteAdminUser,
usePlugins,
useUpdateAdminUser,
useUpdatePlugin,
type UserItem,
} from '../api/admin'
import { stripEmpty } from '../utils/form'
// ── Plugins Tab ───────────────────────────────────────────────────────────────
function PluginsTab() {
const { data: plugins, isLoading } = usePlugins()
const updatePlugin = useUpdatePlugin()
const toggle = async (name: string, enabled: boolean) => {
try {
await updatePlugin.mutateAsync({ name, enabled })
message.success(enabled ? `Плагин «${name}» включён` : `Плагин «${name}» отключён`)
} catch {
message.error('Ошибка при обновлении плагина')
}
}
const columns = [
{
title: 'Название',
key: 'display_name',
render: (_: unknown, row: (typeof plugins)[0]) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{row.display_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.name}
</Typography.Text>
</Space>
),
},
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
render: (v: string | null) => v ?? '—',
},
{
title: 'Категории',
dataIndex: 'compatible_categories',
key: 'categories',
render: (cats: string[]) =>
cats.length === 0 ? (
<Tag color="default">Все</Tag>
) : (
cats.map((c) => <Tag key={c}>{c}</Tag>)
),
},
{
title: 'Статус',
key: 'enabled',
width: 100,
render: (_: unknown, row: (typeof plugins)[0]) => (
<Switch
checked={row.enabled}
onChange={(val) => toggle(row.name, val)}
loading={updatePlugin.isPending}
checkedChildren="Вкл"
unCheckedChildren="Откл"
/>
),
},
]
return (
<Table
dataSource={plugins ?? []}
columns={columns}
rowKey="name"
loading={isLoading}
size="middle"
pagination={false}
/>
)
}
// ── Users Tab ─────────────────────────────────────────────────────────────────
function UsersTab() {
const { data: users, isLoading } = useAdminUsers()
const createUser = useCreateAdminUser()
const updateUser = useUpdateAdminUser()
const deleteUser = useDeleteAdminUser()
const [createModal, setCreateModal] = useState(false)
const [editTarget, setEditTarget] = useState<UserItem | null>(null)
const [form] = Form.useForm()
const openCreate = () => {
form.resetFields()
setEditTarget(null)
setCreateModal(true)
}
const openEdit = (user: UserItem) => {
form.resetFields()
form.setFieldsValue({
email: user.email ?? '',
full_name: user.full_name ?? '',
role: user.role,
is_active: user.is_active,
})
setEditTarget(user)
setCreateModal(true)
}
const handleSave = async () => {
const values = await form.validateFields()
const payload = stripEmpty(values)
try {
if (editTarget) {
await updateUser.mutateAsync({ id: editTarget.id, body: payload as any })
message.success('Пользователь обновлён')
} else {
await createUser.mutateAsync(payload as any)
message.success('Пользователь создан')
}
setCreateModal(false)
setEditTarget(null)
} catch (err: any) {
const detail = err?.response?.data?.detail
message.error(detail ?? 'Ошибка при сохранении')
}
}
const handleDelete = async (id: number) => {
try {
await deleteUser.mutateAsync(id)
message.success('Пользователь удалён')
} catch (err: any) {
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении')
}
}
const columns = [
{
title: 'Логин',
dataIndex: 'username',
key: 'username',
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{ title: 'Email', dataIndex: 'email', key: 'email', render: (v: string | null) => v ?? '—' },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
render: (v: string) => (
<Tag color={v === 'admin' ? 'red' : 'blue'}>{v}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'is_active',
key: 'is_active',
render: (v: boolean) =>
v ? <Badge status="success" text="Активен" /> : <Badge status="default" text="Неактивен" />,
},
{
title: '',
key: 'actions',
width: 100,
render: (_: unknown, row: UserItem) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)} />
<Popconfirm
title="Удалить пользователя?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Создать пользователя
</Button>
</div>
<Table
dataSource={users ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={false}
/>
<Modal
title={editTarget ? `Редактировать: ${editTarget.username}` : 'Создать пользователя'}
open={createModal}
onCancel={() => { setCreateModal(false); setEditTarget(null) }}
onOk={handleSave}
confirmLoading={createUser.isPending || updateUser.isPending}
okText={editTarget ? 'Сохранить' : 'Создать'}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
{!editTarget && (
<>
<Form.Item name="username" label="Логин" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
</>
)}
{editTarget && (
<Form.Item name="password" label="Новый пароль (оставьте пустым для сохранения)">
<Input.Password />
</Form.Item>
)}
<Row gutter={12}>
<Col span={12}>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="full_name" label="Полное имя">
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="role" label="Роль" initialValue="operator">
<Select options={[
{ value: 'admin', label: 'Администратор' },
{ value: 'operator', label: 'Оператор' },
]} />
</Form.Item>
</Col>
{editTarget && (
<Col span={12}>
<Form.Item name="is_active" label="Статус" valuePropName="checked">
<Switch checkedChildren="Активен" unCheckedChildren="Неактивен" />
</Form.Item>
</Col>
)}
</Row>
</Form>
</Modal>
</>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export function AdminPage() {
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={[{ title: 'Администрирование' }]} />
<Typography.Title level={4} style={{ marginBottom: 16 }}>
Администрирование
</Typography.Title>
<Tabs
defaultActiveKey="plugins"
items={[
{
key: 'plugins',
label: (
<span>
<ApiOutlined /> Плагины
</span>
),
children: <PluginsTab />,
},
{
key: 'users',
label: (
<span>
<UserOutlined /> Пользователи
</span>
),
children: <UsersTab />,
},
]}
/>
</div>
)
}

View file

@ -16,7 +16,7 @@ export function LoginPage() {
setError(null) setError(null)
try { try {
const { data } = await api.post('/api/v1/auth/login', values) const { data } = await api.post('/api/v1/auth/login', values)
setTokens(data.access_token, data.refresh_token, values.username) setTokens(data.access_token, data.refresh_token, data.username || values.username, data.role || 'operator')
navigate('/objects') navigate('/objects')
} catch { } catch {
setError('Неверный логин или пароль') setError('Неверный логин или пароль')

View file

@ -2,6 +2,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileTextOutlined, FileTextOutlined,
GoogleOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SyncOutlined, SyncOutlined,
@ -44,6 +45,7 @@ import {
useObject, useObject,
usePreviewCSV, usePreviewCSV,
useSyncObject, useSyncObject,
useSyncSheets,
type DeviceImportPreview, type DeviceImportPreview,
type DeviceItem, type DeviceItem,
} from '../api/objects' } from '../api/objects'
@ -64,6 +66,7 @@ export function ObjectDetailPage() {
const { data: racks } = useRacks(objId) const { data: racks } = useRacks(objId)
const { data: zones } = useZones(objId) const { data: zones } = useZones(objId)
const syncMutation = useSyncObject(objId) const syncMutation = useSyncObject(objId)
const syncSheetsMutation = useSyncSheets(objId)
const { data: actions } = useActions() const { data: actions } = useActions()
const createTask = useCreateTask() const createTask = useCreateTask()
const createDevice = useCreateDevice(objId) const createDevice = useCreateDevice(objId)
@ -81,6 +84,7 @@ export function ObjectDetailPage() {
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null) const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
const [csvModal, setCsvModal] = useState(false) const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false) const [rackModal, setRackModal] = useState(false)
const [sheetsModal, setSheetsModal] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null) const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null) const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
@ -89,6 +93,7 @@ export function ObjectDetailPage() {
const [deviceForm] = Form.useForm() const [deviceForm] = Form.useForm()
const [rackForm] = Form.useForm() const [rackForm] = Form.useForm()
const [zoneForm] = Form.useForm() const [zoneForm] = Form.useForm()
const [sheetsForm] = Form.useForm()
const [selectedAction, setSelectedAction] = useState<string>('') const [selectedAction, setSelectedAction] = useState<string>('')
const currentAction = actions?.find((a) => a.name === selectedAction) const currentAction = actions?.find((a) => a.name === selectedAction)
@ -130,6 +135,23 @@ export function ObjectDetailPage() {
} }
} }
const handleSyncSheets = async () => {
try {
const values = await sheetsForm.validateFields()
const result = await syncSheetsMutation.mutateAsync({
spreadsheet_id: values.spreadsheet_id,
sheet_name: values.sheet_name || undefined,
})
message.success(`Google Sheets: +${result.created} создано, ${result.updated} обновлено`)
setSheetsModal(false)
sheetsForm.resetFields()
} catch (err: any) {
const detail = err?.response?.data?.detail
if (detail) message.error(detail)
// validation errors are shown inline — no extra message needed
}
}
const openAddDevice = () => { const openAddDevice = () => {
deviceForm.resetFields() deviceForm.resetFields()
setEditDevice(null) setEditDevice(null)
@ -473,6 +495,9 @@ export function ObjectDetailPage() {
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}> <Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
Импорт CSV Импорт CSV
</Button> </Button>
<Button icon={<GoogleOutlined />} onClick={() => setSheetsModal(true)}>
Google Sheets
</Button>
<Button icon={<PlusOutlined />} onClick={openAddDevice}> <Button icon={<PlusOutlined />} onClick={openAddDevice}>
Добавить устройство Добавить устройство
</Button> </Button>
@ -788,6 +813,37 @@ export function ObjectDetailPage() {
</Form> </Form>
</Modal> </Modal>
{/* ── Google Sheets Sync Modal ── */}
<Modal
title="Синхронизация из Google Sheets"
open={sheetsModal}
onCancel={() => { setSheetsModal(false); sheetsForm.resetFields() }}
onOk={handleSyncSheets}
confirmLoading={syncSheetsMutation.isPending}
okText="Синхронизировать"
>
<Form form={sheetsForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="spreadsheet_id"
label="ID таблицы Google Sheets"
rules={[{ required: true, message: 'Введите ID таблицы' }]}
extra="Часть URL после /spreadsheets/d/ и перед /edit"
>
<Input placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" />
</Form.Item>
<Form.Item
name="sheet_name"
label="Название листа (необязательно)"
extra="По умолчанию используется первый лист"
>
<Input placeholder="Sheet1" />
</Form.Item>
</Form>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
</Typography.Text>
</Modal>
{/* ── Task Modal ── */} {/* ── Task Modal ── */}
<Modal <Modal
title="Создать задачу" title="Создать задачу"

View file

@ -5,7 +5,8 @@ interface AuthState {
accessToken: string | null accessToken: string | null
refreshToken: string | null refreshToken: string | null
username: string | null username: string | null
setTokens: (access: string, refresh: string, username: string) => void role: string | null
setTokens: (access: string, refresh: string, username: string, role: string) => void
clearTokens: () => void clearTokens: () => void
} }
@ -15,10 +16,11 @@ export const useAuthStore = create<AuthState>()(
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
username: null, username: null,
setTokens: (access, refresh, username) => role: null,
set({ accessToken: access, refreshToken: refresh, username }), setTokens: (access, refresh, username, role) =>
set({ accessToken: access, refreshToken: refresh, username, role }),
clearTokens: () => clearTokens: () =>
set({ accessToken: null, refreshToken: null, username: null }), set({ accessToken: null, refreshToken: null, username: null, role: null }),
}), }),
{ name: 'fleet-auth' } { name: 'fleet-auth' }
) )