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.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.startup import recover_stale_tasks
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
load_plugins()
|
||||
await load_plugins()
|
||||
await recover_stale_tasks()
|
||||
start_monitor()
|
||||
yield
|
||||
|
|
@ -45,6 +45,7 @@ def create_app() -> FastAPI:
|
|||
app.include_router(racks.router)
|
||||
app.include_router(zones.router)
|
||||
app.include_router(tags.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(snapshots.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.
|
||||
Étape 5: will scan plugins/custom/ and cross-reference with the plugins DB table.
|
||||
Startup flow:
|
||||
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.builtin.camera.get_status import CameraGetStatusAction
|
||||
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.ssh_command import SSHCommandAction
|
||||
from app.plugins.registry import action_registry
|
||||
from app.services.database import AsyncSessionLocal
|
||||
|
||||
_BUILTIN_ACTIONS: list[BaseAction] = [
|
||||
# Universal
|
||||
# Public dict — used by Admin router for hot enable/disable
|
||||
BUILTIN_ACTIONS: dict[str, BaseAction] = {
|
||||
a.name: a
|
||||
for a in [
|
||||
PingAction(),
|
||||
# SSH / Linux
|
||||
SSHCommandAction(),
|
||||
GetTimedatectlAction(),
|
||||
GetFileAction(),
|
||||
# Mikrotik (RouterOS via SSH)
|
||||
MikrotikGetConfigAction(),
|
||||
MikrotikGetNTPAction(),
|
||||
MikrotikSetNTPAction(),
|
||||
# Cameras (Hikvision ISAPI)
|
||||
CameraGetStatusAction(),
|
||||
CameraSetNTPAction(),
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def load_plugins() -> None:
|
||||
for action in _BUILTIN_ACTIONS:
|
||||
async def load_plugins() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
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]:
|
||||
return list(self._actions.values())
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
self._actions.pop(name, None)
|
||||
|
||||
def for_category(self, category: str) -> list[BaseAction]:
|
||||
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,
|
||||
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)
|
||||
|
|
@ -36,7 +41,12 @@ async def refresh(body: RefreshRequest, request: Request, db: AsyncSession = Dep
|
|||
|
||||
user, new_refresh_token = result
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ from app.models.device import Device
|
|||
from app.models.object import Object
|
||||
from app.models.tag import Tag
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import SheetsSyncRequest
|
||||
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate, SyncResult
|
||||
from app.services import crypto
|
||||
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"])
|
||||
|
||||
|
|
@ -136,3 +138,28 @@ async def sync_devices(
|
|||
skipped=result.skipped,
|
||||
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
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
username: str = ""
|
||||
role: str = "operator"
|
||||
|
||||
|
||||
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",
|
||||
"httpx>=0.28",
|
||||
"asyncssh>=2.18",
|
||||
# Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var)
|
||||
"gspread>=6.0",
|
||||
"google-auth>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import { AdminPage } from './pages/Admin'
|
||||
import { AlertsPage } from './pages/Alerts'
|
||||
import { LoginPage } from './pages/Login'
|
||||
import { ObjectDetailPage } from './pages/ObjectDetail'
|
||||
|
|
@ -27,6 +28,7 @@ export default function App() {
|
|||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/objects" replace />} />
|
||||
<Route path="admin" element={<AdminPage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
<Route path="objects" element={<ObjectsPage />} />
|
||||
<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] }),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
BellOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Badge, Layout, Menu, Typography } from 'antd'
|
||||
|
|
@ -18,7 +19,7 @@ export function AppLayout() {
|
|||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const qc = useQueryClient()
|
||||
const { username, refreshToken, clearTokens } = useAuthStore()
|
||||
const { username, role, refreshToken, clearTokens } = useAuthStore()
|
||||
|
||||
// Open alert count for sidebar badge — refresh every 60s
|
||||
const { data: openAlerts } = useAlerts({
|
||||
|
|
@ -42,6 +43,8 @@ export function AppLayout() {
|
|||
? 'tasks'
|
||||
: location.pathname.startsWith('/alerts')
|
||||
? 'alerts'
|
||||
: location.pathname.startsWith('/admin')
|
||||
? 'admin'
|
||||
: 'objects'
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
|
@ -91,6 +94,16 @@ export function AppLayout() {
|
|||
label: 'Задачи',
|
||||
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' }}>
|
||||
|
|
|
|||
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)
|
||||
try {
|
||||
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')
|
||||
} catch {
|
||||
setError('Неверный логин или пароль')
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
GoogleOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
SyncOutlined,
|
||||
|
|
@ -44,6 +45,7 @@ import {
|
|||
useObject,
|
||||
usePreviewCSV,
|
||||
useSyncObject,
|
||||
useSyncSheets,
|
||||
type DeviceImportPreview,
|
||||
type DeviceItem,
|
||||
} from '../api/objects'
|
||||
|
|
@ -64,6 +66,7 @@ export function ObjectDetailPage() {
|
|||
const { data: racks } = useRacks(objId)
|
||||
const { data: zones } = useZones(objId)
|
||||
const syncMutation = useSyncObject(objId)
|
||||
const syncSheetsMutation = useSyncSheets(objId)
|
||||
const { data: actions } = useActions()
|
||||
const createTask = useCreateTask()
|
||||
const createDevice = useCreateDevice(objId)
|
||||
|
|
@ -81,6 +84,7 @@ export function ObjectDetailPage() {
|
|||
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
|
||||
const [csvModal, setCsvModal] = useState(false)
|
||||
const [rackModal, setRackModal] = useState(false)
|
||||
const [sheetsModal, setSheetsModal] = useState(false)
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
|
@ -89,6 +93,7 @@ export function ObjectDetailPage() {
|
|||
const [deviceForm] = Form.useForm()
|
||||
const [rackForm] = Form.useForm()
|
||||
const [zoneForm] = Form.useForm()
|
||||
const [sheetsForm] = Form.useForm()
|
||||
const [selectedAction, setSelectedAction] = useState<string>('')
|
||||
|
||||
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 = () => {
|
||||
deviceForm.resetFields()
|
||||
setEditDevice(null)
|
||||
|
|
@ -473,6 +495,9 @@ export function ObjectDetailPage() {
|
|||
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
|
||||
Импорт CSV
|
||||
</Button>
|
||||
<Button icon={<GoogleOutlined />} onClick={() => setSheetsModal(true)}>
|
||||
Google Sheets
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={openAddDevice}>
|
||||
Добавить устройство
|
||||
</Button>
|
||||
|
|
@ -788,6 +813,37 @@ export function ObjectDetailPage() {
|
|||
</Form>
|
||||
</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 ── */}
|
||||
<Modal
|
||||
title="Создать задачу"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ interface AuthState {
|
|||
accessToken: string | null
|
||||
refreshToken: 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
|
||||
}
|
||||
|
||||
|
|
@ -15,10 +16,11 @@ export const useAuthStore = create<AuthState>()(
|
|||
accessToken: null,
|
||||
refreshToken: null,
|
||||
username: null,
|
||||
setTokens: (access, refresh, username) =>
|
||||
set({ accessToken: access, refreshToken: refresh, username }),
|
||||
role: null,
|
||||
setTokens: (access, refresh, username, role) =>
|
||||
set({ accessToken: access, refreshToken: refresh, username, role }),
|
||||
clearTokens: () =>
|
||||
set({ accessToken: null, refreshToken: null, username: null }),
|
||||
set({ accessToken: null, refreshToken: null, username: null, role: null }),
|
||||
}),
|
||||
{ name: 'fleet-auth' }
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue