diff --git a/backend/alembic/versions/0007_plugins.py b/backend/alembic/versions/0007_plugins.py new file mode 100644 index 0000000..a1605ae --- /dev/null +++ b/backend/alembic/versions/0007_plugins.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index bae006d..e833f85 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py new file mode 100644 index 0000000..d761f1c --- /dev/null +++ b/backend/app/models/plugin.py @@ -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) diff --git a/backend/app/plugins/loader.py b/backend/app/plugins/loader.py index 77a40d4..9f527f8 100644 --- a/backend/app/plugins/loader.py +++ b/backend/app/plugins/loader.py @@ -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 - PingAction(), - # SSH / Linux - SSHCommandAction(), - GetTimedatectlAction(), - GetFileAction(), - # Mikrotik (RouterOS via SSH) - MikrotikGetConfigAction(), - MikrotikGetNTPAction(), - MikrotikSetNTPAction(), - # Cameras (Hikvision ISAPI) - CameraGetStatusAction(), - CameraSetNTPAction(), -] +# Public dict — used by Admin router for hot enable/disable +BUILTIN_ACTIONS: dict[str, BaseAction] = { + a.name: a + for a in [ + PingAction(), + SSHCommandAction(), + GetTimedatectlAction(), + GetFileAction(), + MikrotikGetConfigAction(), + MikrotikGetNTPAction(), + MikrotikSetNTPAction(), + CameraGetStatusAction(), + CameraSetNTPAction(), + ] +} -def load_plugins() -> None: - for action in _BUILTIN_ACTIONS: - action_registry.register(action) +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)") diff --git a/backend/app/plugins/registry.py b/backend/app/plugins/registry.py index bf8c80f..c4e3d6c 100644 --- a/backend/app/plugins/registry.py +++ b/backend/app/plugins/registry.py @@ -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)] diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..2aeb28c --- /dev/null +++ b/backend/app/routers/admin.py @@ -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) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8234f1c..580f6bf 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index c1421a4..a011552 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -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, + ) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..007ffb5 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -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 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 143044f..a01370f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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): diff --git a/backend/app/services/sheets.py b/backend/app/services/sheets.py new file mode 100644 index 0000000..6b5753d --- /dev/null +++ b/backend/app/services/sheets.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 219dc8e..6207f71 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43550cb..6b45927 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } > } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..f5401e6 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -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('/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(`/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('/api/v1/admin/users').then((r) => r.data), + }) + +export const useCreateAdminUser = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: UserCreatePayload) => + api.post('/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(`/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'] }), + }) +} diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index aec2a4a..b2ac56f 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -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(`/api/v1/objects/${objId}/sync/sheets`, body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + }) +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 22847ed..578a1a3 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -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: , + label: 'Администрирование', + onClick: () => navigate('/admin'), + }, + ] + : []), ]} />
diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..8e06b30 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -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]) => ( + + {row.display_name} + + {row.name} + + + ), + }, + { + title: 'Описание', + dataIndex: 'description', + key: 'description', + render: (v: string | null) => v ?? '—', + }, + { + title: 'Категории', + dataIndex: 'compatible_categories', + key: 'categories', + render: (cats: string[]) => + cats.length === 0 ? ( + Все + ) : ( + cats.map((c) => {c}) + ), + }, + { + title: 'Статус', + key: 'enabled', + width: 100, + render: (_: unknown, row: (typeof plugins)[0]) => ( + toggle(row.name, val)} + loading={updatePlugin.isPending} + checkedChildren="Вкл" + unCheckedChildren="Откл" + /> + ), + }, + ] + + return ( + + ) +} + +// ── 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(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) => {v}, + }, + { title: 'Email', dataIndex: 'email', key: 'email', render: (v: string | null) => v ?? '—' }, + { + title: 'Роль', + dataIndex: 'role', + key: 'role', + render: (v: string) => ( + {v} + ), + }, + { + title: 'Статус', + dataIndex: 'is_active', + key: 'is_active', + render: (v: boolean) => + v ? : , + }, + { + title: '', + key: 'actions', + width: 100, + render: (_: unknown, row: UserItem) => ( + + + + +
+ + { setCreateModal(false); setEditTarget(null) }} + onOk={handleSave} + confirmLoading={createUser.isPending || updateUser.isPending} + okText={editTarget ? 'Сохранить' : 'Создать'} + > +
+ {!editTarget && ( + <> + + + + + + + + )} + {editTarget && ( + + + + )} + +
+ + + + + + + + + + + + + + + + + + + + + Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes + + + {/* ── Task Modal ── */} void + role: string | null + setTokens: (access: string, refresh: string, username: string, role: string) => void clearTokens: () => void } @@ -15,10 +16,11 @@ export const useAuthStore = create()( 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' } )