vibe1
This commit is contained in:
parent
6b36485718
commit
402d3d6a21
20 changed files with 1047 additions and 31 deletions
29
backend/alembic/versions/0007_plugins.py
Normal file
29
backend/alembic/versions/0007_plugins.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
19
backend/app/models/plugin.py
Normal file
19
backend/app/models/plugin.py
Normal 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)
|
||||||
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
|
||||||
160
backend/app/routers/admin.py
Normal file
160
backend/app/routers/admin.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
58
backend/app/schemas/admin.py
Normal file
58
backend/app/schemas/admin.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
165
backend/app/services/sheets.py
Normal file
165
backend/app/services/sheets.py
Normal 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
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
88
frontend/src/api/admin.ts
Normal 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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' }}>
|
||||||
|
|
|
||||||
323
frontend/src/pages/Admin.tsx
Normal file
323
frontend/src/pages/Admin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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('Неверный логин или пароль')
|
||||||
|
|
|
||||||
|
|
@ -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="Создать задачу"
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue