diff --git a/backend/app/plugins/base.py b/backend/app/plugins/base.py index 4d2f152..deaf893 100644 --- a/backend/app/plugins/base.py +++ b/backend/app/plugins/base.py @@ -50,3 +50,39 @@ class BaseAction(ABC): def is_compatible(self, category: str) -> bool: return not self.compatible_categories or category in self.compatible_categories + + +class CompositeAction(BaseAction): + """Dispatches to different sub-actions based on device category. + + Define routing as {category: action_instance}. The composite action's + compatible_categories is derived automatically from the routing keys. + Devices with a category not in routing are skipped with an error result. + """ + + def __init__( + self, + name: str, + display_name: str, + routing: dict[str, "BaseAction"], + params_schema: list[dict] | None = None, + ) -> None: + self.name = name + self.display_name = display_name + self._routing = routing + self.compatible_categories = list(routing.keys()) + self.params_schema = params_schema or [] + + async def execute( + self, + device: Device, + obj: Object, + params: dict, + emit: Emitter, + ) -> ActionResult: + action = self._routing.get(device.category) + if action is None: + msg = f"[{device.ip}] Нет обработчика для категории '{device.category}'" + await emit(msg, "error") + return ActionResult(success=False, error=msg) + return await action.execute(device, obj, params, emit) diff --git a/backend/app/plugins/loader.py b/backend/app/plugins/loader.py index 0547728..7f10794 100644 --- a/backend/app/plugins/loader.py +++ b/backend/app/plugins/loader.py @@ -18,7 +18,7 @@ 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, CompositeAction from app.plugins.builtin.camera.get_status import CameraGetStatusAction from app.plugins.builtin.camera.set_ntp import CameraSetNTPAction from app.plugins.builtin.common.ping import PingAction @@ -32,6 +32,27 @@ from app.plugins.builtin.ssh.ssh_command import SSHCommandAction from app.plugins.registry import action_registry from app.services.database import AsyncSessionLocal +# Shared sub-action instances reused inside composite actions +_timedatectl = GetTimedatectlAction() +_mikrotik_get_ntp = MikrotikGetNTPAction() +_camera_get_status = CameraGetStatusAction() + +# Composite: Get NTP Info — routes by device category +_get_ntp_info = CompositeAction( + name="get_ntp_info", + display_name="Get NTP Info", + routing={ + "main_server": _timedatectl, + "vm": _timedatectl, + "server": _timedatectl, + "raspberry": _timedatectl, + "embedded": _timedatectl, + "router": _mikrotik_get_ntp, + "mikrotik": _mikrotik_get_ntp, + "camera": _camera_get_status, + }, +) + # Public dict — used by Admin router for hot enable/disable BUILTIN_ACTIONS: dict[str, BaseAction] = { a.name: a @@ -39,13 +60,14 @@ BUILTIN_ACTIONS: dict[str, BaseAction] = { PingAction(), SSHCommandAction(), DebianSystemInfoAction(), - GetTimedatectlAction(), + _timedatectl, GetFileAction(), MikrotikGetConfigAction(), - MikrotikGetNTPAction(), + _mikrotik_get_ntp, MikrotikSetNTPAction(), - CameraGetStatusAction(), + _camera_get_status, CameraSetNTPAction(), + _get_ntp_info, ] } diff --git a/backend/app/services/task.py b/backend/app/services/task.py index a971182..0c741c1 100644 --- a/backend/app/services/task.py +++ b/backend/app/services/task.py @@ -233,6 +233,23 @@ async def _run_task(task_id: str) -> None: return targets = await _resolve_scope(scope) + + # Filter devices by action compatibility (compatible_categories) + if action.compatible_categories: + compatible = [(d, o) for d, o in targets if action.is_compatible(d.category)] + skipped = [(d, o) for d, o in targets if not action.is_compatible(d.category)] + if skipped: + skipped_info = ", ".join( + f"{d.ip} ({d.category})" for d, _ in skipped[:10] + ) + suffix = f" и ещё {len(skipped) - 10}" if len(skipped) > 10 else "" + await _emit( + task_id, + f"Пропущено {len(skipped)} несовместимых устройств: {skipped_info}{suffix}", + "warn", + ) + targets = compatible + if not targets: async with AsyncSessionLocal() as db: async with db.begin():