diff --git a/backend/alembic/versions/0014_plugin_compatible_categories.py b/backend/alembic/versions/0014_plugin_compatible_categories.py new file mode 100644 index 0000000..3cc37de --- /dev/null +++ b/backend/alembic/versions/0014_plugin_compatible_categories.py @@ -0,0 +1,31 @@ +"""0014 plugin compatible_categories + +Revision ID: 0014 +Revises: 0013 +Create Date: 2026-04-10 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0014" +down_revision = "0013" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "plugins", + sa.Column( + "compatible_categories", + postgresql.ARRAY(sa.String()), + nullable=False, + server_default="{}", + ), + ) + + +def downgrade() -> None: + op.drop_column("plugins", "compatible_categories") diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py index d761f1c..50fb16a 100644 --- a/backend/app/models/plugin.py +++ b/backend/app/models/plugin.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Optional from sqlalchemy import Boolean, DateTime, String, Text +from sqlalchemy.dialects.postgresql import ARRAY as PgARRAY from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -17,3 +18,6 @@ class Plugin(Base): 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) + compatible_categories: Mapped[list[str]] = mapped_column( + PgARRAY(String()), nullable=False, default=list, server_default="{}" + ) diff --git a/backend/app/plugins/loader.py b/backend/app/plugins/loader.py index 7f10794..72af1e2 100644 --- a/backend/app/plugins/loader.py +++ b/backend/app/plugins/loader.py @@ -75,6 +75,7 @@ BUILTIN_ACTIONS: dict[str, BaseAction] = { async def load_plugins() -> None: now = datetime.now(timezone.utc) + # Insert new plugins with code defaults; preserve existing DB rows (including user edits) async with AsyncSessionLocal() as db: async with db.begin(): for action in BUILTIN_ACTIONS.values(): @@ -86,19 +87,28 @@ async def load_plugins() -> None: description=(action.__class__.__doc__ or "").strip() or None, enabled=True, registered_at=now, + compatible_categories=action.compatible_categories, ) .on_conflict_do_nothing(index_elements=["name"]) ) await db.execute(stmt) - # Load enabled plugins from DB + # Load all plugins from DB; apply stored categories to action instances 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()} + result = await db.execute( + select(Plugin.name, Plugin.enabled, Plugin.compatible_categories) + ) + rows = result.all() - # Register only enabled actions - for name, action in BUILTIN_ACTIONS.items(): - if name in enabled_names: + enabled_count = 0 + for name, enabled, categories in rows: + action = BUILTIN_ACTIONS.get(name) + if action is None: + continue + # Override in-memory categories with whatever is stored in DB + action.compatible_categories = categories or [] + if enabled: action_registry.register(action) + enabled_count += 1 - print(f"[plugins] Registered {len(enabled_names)}/{len(BUILTIN_ACTIONS)} plugin(s)") + print(f"[plugins] Registered {enabled_count}/{len(BUILTIN_ACTIONS)} plugin(s)") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 26f79d0..d3b6153 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -58,25 +58,29 @@ async def update_plugin( if plugin is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found") - plugin.enabled = body.enabled + action = BUILTIN_ACTIONS.get(name) - # Hot-reload the registry immediately - if body.enabled: - action = BUILTIN_ACTIONS.get(name) + if body.enabled is not None: + plugin.enabled = body.enabled + if body.enabled: + if action: + action_registry.register(action) + else: + action_registry.unregister(name) + + if body.compatible_categories is not None: + plugin.compatible_categories = body.compatible_categories if action: - action_registry.register(action) - else: - action_registry.unregister(name) + action.compatible_categories = body.compatible_categories 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 [], + compatible_categories=plugin.compatible_categories, ) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 007ffb5..72cb89a 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -18,7 +18,8 @@ class PluginRead(BaseModel): class PluginUpdate(BaseModel): - enabled: bool + enabled: Optional[bool] = None + compatible_categories: Optional[list[str]] = None # ── Users ────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 6ba0a28..ca2a3d5 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -21,8 +21,18 @@ export const usePlugins = () => 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), + mutationFn: ({ + name, + enabled, + compatible_categories, + }: { + name: string + enabled?: boolean + compatible_categories?: string[] + }) => + api + .patch(`/api/v1/admin/plugins/${name}`, { enabled, compatible_categories }) + .then((r) => r.data), onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-plugins'] }), }) } diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 03da479..d2c5abb 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -6,6 +6,7 @@ import { PlusOutlined, UserOutlined, ApiOutlined, + SettingOutlined, } from '@ant-design/icons' import { Badge, @@ -45,11 +46,20 @@ import { import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials' import { stripEmpty } from '../utils/form' +const ALL_DEVICE_CATEGORIES = [ + 'main_server', 'vm', 'router', 'embedded', 'camera', + 'io_board', 'bank_terminal', 'cash_register', 'other', + 'server', 'raspberry', 'mikrotik', +] + // ── Plugins Tab ─────────────────────────────────────────────────────────────── function PluginsTab() { const { data: plugins, isLoading } = usePlugins() const updatePlugin = useUpdatePlugin() + const [editTarget, setEditTarget] = useState<(typeof plugins)[0] | null>(null) + const [editCategories, setEditCategories] = useState([]) + const toggle = async (name: string, enabled: boolean) => { try { await updatePlugin.mutateAsync({ name, enabled }) @@ -59,6 +69,22 @@ function PluginsTab() { } } + const openEdit = (row: (typeof plugins)[0]) => { + setEditTarget(row) + setEditCategories(row.compatible_categories) + } + + const handleSaveCategories = async () => { + if (!editTarget) return + try { + await updatePlugin.mutateAsync({ name: editTarget.name, compatible_categories: editCategories }) + message.success('Категории обновлены') + setEditTarget(null) + } catch { + message.error('Ошибка при сохранении') + } + } + const columns = [ { title: 'Название', @@ -80,14 +106,22 @@ function PluginsTab() { }, { title: 'Категории', - dataIndex: 'compatible_categories', key: 'categories', - render: (cats: string[]) => - cats.length === 0 ? ( - Все - ) : ( - cats.map((c) => {c}) - ), + render: (_: unknown, row: (typeof plugins)[0]) => ( + + {row.compatible_categories.length === 0 ? ( + Все + ) : ( + row.compatible_categories.map((c) => {c}) + )} +