diff --git a/app/sep/main.py b/app/sep/main.py
index b9d3aac719..9bfbe2942a 100644
--- a/app/sep/main.py
+++ b/app/sep/main.py
@@ -138,6 +138,7 @@ async def sep_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"backup_mongo",
"backup_pg",
"checksums",
+ "gascan",
}
)
diff --git a/app/sep/plugins/gascan/__init__.py b/app/sep/plugins/gascan/__init__.py
new file mode 100644
index 0000000000..1bf01b4c13
--- /dev/null
+++ b/app/sep/plugins/gascan/__init__.py
@@ -0,0 +1,20 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Gascan management plugin."""
+
+from app.sep.plugins.gascan.routes import router
+
+__all__ = ["router"]
diff --git a/app/sep/plugins/gascan/api_routes.py b/app/sep/plugins/gascan/api_routes.py
new file mode 100644
index 0000000000..917dbee018
--- /dev/null
+++ b/app/sep/plugins/gascan/api_routes.py
@@ -0,0 +1,93 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define the JSON API router for the Gascan plugin.
+
+Mounted at ``/api/plugins/gascan/`` via ``plugins_router`` in
+``app/sep/api/router.py``.
+"""
+
+import logging
+
+from fastapi import APIRouter
+from fastapi import status as http_status
+
+from app.sep.deps import TaskAPI
+from app.sep.plugins.framework.api import schema_endpoint
+from app.sep.plugins.gascan.deps import (
+ build_gascan_api_task_response,
+ build_gascan_task,
+ GascanTask,
+ get_gascan_api_task_responses,
+ get_gascan_task,
+ get_gascan_task_status,
+)
+from app.sep.plugins.gascan.models import GascanTaskResponse, GascanTaskWrite
+from app.sep.plugins.gascan.schema import gascan_schema
+from app.tasks.models import Task, TaskHistoryStatusEnum
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+schema_endpoint(router=router, plugin_schema=gascan_schema)
+
+
+@router.get("/", response_model=list[GascanTaskResponse])
+async def gascan_api_list(
+ tasks_api: TaskAPI,
+ status: TaskHistoryStatusEnum | None = None,
+) -> list[GascanTaskResponse]:
+ """List gascan tasks."""
+ return await get_gascan_api_task_responses(
+ tasks_api,
+ status=status,
+ )
+
+
+@router.get("/{task_name}", response_model=GascanTaskResponse)
+async def gascan_api_detail(
+ task_name: str,
+ tasks_api: TaskAPI,
+) -> GascanTaskResponse:
+ """Retrieve a single gascan task."""
+ task = await get_gascan_task(task_name, tasks_api)
+ task_status = await get_gascan_task_status(task.name, tasks_api)
+ return build_gascan_api_task_response(task, status=task_status)
+
+
+@router.post(
+ "/",
+ response_model=GascanTaskResponse,
+ status_code=http_status.HTTP_201_CREATED,
+)
+async def gascan_api_create(
+ body: GascanTaskWrite,
+ tasks_api: TaskAPI,
+) -> GascanTaskResponse:
+ """Create a gascan task from a JSON payload request body."""
+ logger.debug("Create gascan task (JSON path): %s", body.task_name)
+ task_write = build_gascan_task(body)
+ created = await tasks_api.post("/", json=task_write.model_dump())
+ task = Task.model_validate(created)
+ return build_gascan_api_task_response(task, status=None)
+
+
+@router.delete("/{task_name}", status_code=http_status.HTTP_204_NO_CONTENT)
+async def gascan_api_delete(
+ task: GascanTask,
+ tasks_api: TaskAPI,
+) -> None:
+ """Delete a gascan task."""
+ await tasks_api.delete(f"/{task.name}")
diff --git a/app/sep/plugins/gascan/deps.py b/app/sep/plugins/gascan/deps.py
new file mode 100644
index 0000000000..f1ba7d7e31
--- /dev/null
+++ b/app/sep/plugins/gascan/deps.py
@@ -0,0 +1,321 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define dependencies for the Gascan plugin."""
+
+import logging
+import shlex
+from collections.abc import Iterable
+from typing import Annotated, Any
+
+from fastapi import Depends, Form
+
+from app.sep.deps import (
+ DefaultContext,
+ ExecutorHostsCtx,
+ get_task_by_name,
+ get_tasks_context,
+ InventoryAPI,
+ TaskAPI,
+)
+from app.sep.plugins.gascan.models import (
+ GascanCreate,
+ GascanTaskResponse,
+ GascanTaskWrite,
+)
+from app.tasks.models import (
+ Task,
+ TaskBackendEnum,
+ TaskHistoryStatusEnum,
+ TaskOwner,
+ TaskWrite,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _assemble_gascan_payload(
+ *,
+ task_name: str,
+ hostname: str,
+ playbook: str = "",
+ limit: str = "",
+ override: str = "",
+ alert_on_fail: bool = False,
+) -> TaskWrite:
+ """Build a TaskWrite payload for a gascan run-command job.
+
+ :param task_name: The name of the task.
+ :type task_name: str
+ :param hostname: The executor host where gascan runs.
+ :type hostname: str
+ :param playbook: The playbook to run.
+ :type playbook: str
+ :param limit: Optional limit expression.
+ :type limit: str
+ :param override: Optional override values.
+ :type override: str
+ :param alert_on_fail: Whether to alert on task failure.
+ :type alert_on_fail: bool
+ :return: A fully constructed TaskWrite for the Tasks API.
+ :rtype: TaskWrite
+ """
+ args: list[str] = []
+ if playbook:
+ args.append(f"-playbook={playbook}")
+ if limit:
+ args.append(f"-limit={limit}")
+ if override:
+ args.append(f"-override={override}")
+
+ return TaskWrite(
+ owner=TaskOwner.GASCAN,
+ backend=TaskBackendEnum.PROXY,
+ data={
+ "task": "run-command",
+ "meta": {
+ "command": "gascan",
+ "args": shlex.join(args),
+ "target": hostname,
+ },
+ },
+ name=task_name,
+ target=hostname,
+ alert_on_fail=alert_on_fail,
+ )
+
+
+async def build_gascan_task_payload(
+ form: Annotated[GascanCreate, Form()],
+) -> TaskWrite:
+ """Build the gascan task payload from an HTML form submission.
+
+ :param form: The form data for Gascan task creation.
+ :type form: GascanCreate
+ :return: A fully constructed TaskWrite object.
+ :rtype: TaskWrite
+ """
+ return _assemble_gascan_payload(
+ task_name=form.task_name,
+ hostname=form.hostname,
+ playbook=form.playbook,
+ limit=form.limit,
+ override=form.override,
+ alert_on_fail=form.alert_on_fail,
+ )
+
+
+GascanGeneratedTask = Annotated[TaskWrite, Depends(build_gascan_task_payload)]
+
+
+def build_gascan_task(body: GascanTaskWrite) -> TaskWrite:
+ """Build the gascan task payload from a JSON request body.
+
+ :param body: The validated JSON request body.
+ :type body: GascanTaskWrite
+ :return: A fully constructed TaskWrite object.
+ :rtype: TaskWrite
+ """
+ return _assemble_gascan_payload(
+ task_name=body.task_name,
+ hostname=body.hostname,
+ playbook=body.playbook,
+ limit=body.limit,
+ override=body.override,
+ alert_on_fail=body.alert_on_fail,
+ )
+
+
+async def get_gascan_task(
+ task_name: str,
+ tasks_api: TaskAPI,
+) -> Task:
+ """Fetch and validate a task for the Gascan plugin.
+
+ :param task_name: The name of the task to retrieve.
+ :type task_name: str
+ :param tasks_api: The TaskAPI instance used to query the task service.
+ :type tasks_api: TaskAPI
+ :return: The retrieved task.
+ :rtype: Task
+ :raises HTTPNotFoundException: If the task is not found or not owned by Gascan.
+ """
+ return await get_task_by_name(tasks_api, task_name, TaskOwner.GASCAN)
+
+
+GascanTask = Annotated[Task, Depends(get_gascan_task)]
+
+
+def _extract_latest_task_status(
+ histories: Iterable[dict[str, Any]],
+) -> TaskHistoryStatusEnum | None:
+ """Return the latest known status from a task history payload."""
+ for history in histories:
+ if (status := history.get("status")) is not None:
+ return TaskHistoryStatusEnum(status)
+ return None
+
+
+async def get_gascan_task_status(
+ task_name: str,
+ tasks_api: TaskAPI,
+) -> TaskHistoryStatusEnum | None:
+ """Fetch the latest execution status for a gascan task.
+
+ :param task_name: The name of the gascan task.
+ :type task_name: str
+ :param tasks_api: The TaskAPI instance used to query task history.
+ :type tasks_api: TaskAPI
+ :return: The latest known task status, or ``None`` if no history exists.
+ :rtype: TaskHistoryStatusEnum | None
+ """
+ response = await tasks_api.get(f"/{task_name}/history/")
+ return _extract_latest_task_status(response["items"])
+
+
+def build_gascan_api_task_response(
+ task: Task,
+ status: TaskHistoryStatusEnum | None = None,
+) -> GascanTaskResponse:
+ """Build a gascan task response object for the JSON API.
+
+ :param task: The gascan task retrieved from the Tasks API.
+ :type task: Task
+ :param status: The latest known execution status for the task.
+ :type status: TaskHistoryStatusEnum | None
+ :return: A validated gascan task API response object.
+ :rtype: GascanTaskResponse
+ """
+ return GascanTaskResponse(
+ **task.model_dump(),
+ status=status,
+ )
+
+
+async def get_gascan_api_task_responses(
+ tasks_api: TaskAPI,
+ status: TaskHistoryStatusEnum | None = None,
+) -> list[GascanTaskResponse]:
+ """Retrieve gascan task responses for the JSON API.
+
+ :param tasks_api: The TaskAPI instance used to query gascan tasks.
+ :type tasks_api: TaskAPI
+ :param status: Optional latest-history status filter.
+ :type status: TaskHistoryStatusEnum | None
+ :return: The gascan task responses matching the requested filters.
+ :rtype: list[GascanTaskResponse]
+ """
+ params = {"owner": TaskOwner.GASCAN.value}
+ response = await tasks_api.get("/", params=params)
+ tasks = [Task.model_validate(task) for task in response["items"]]
+ task_status_pairs = [
+ (task, await get_gascan_task_status(task.name, tasks_api)) for task in tasks
+ ]
+
+ return [
+ build_gascan_api_task_response(task, status=task_status)
+ for task, task_status in task_status_pairs
+ if status is None or task_status == status
+ ]
+
+
+def get_gascan_task_info(task: dict[str, Any]) -> dict[str, Any]:
+ """Extract relevant information from a task for the Gascan plugin.
+
+ :param task: The task data retrieved from the Tasks API.
+ :type task: dict[str, Any]
+ :return: A dictionary containing hostname and playbook information.
+ :rtype: dict[str, Any]
+ """
+ data = task["data"]
+ meta = data["meta"]
+ form_values = parse_gascan_task_args(meta)
+ return {
+ "hostname": meta["target"],
+ "playbook": form_values.get("playbook", ""),
+ "created_by": task.get("created_by"),
+ "last_updated_by": task.get("last_updated_by"),
+ }
+
+
+def parse_single_gascan_arg(arg: str, form_values: dict[str, Any]) -> None:
+ """Parse a single gascan argument and update form values.
+
+ :param arg: The argument to parse.
+ :type arg: str
+ :param form_values: The form values dictionary to update.
+ :type form_values: dict[str, Any]
+ """
+ arg_mappings = {
+ "-playbook=": "playbook",
+ "-limit=": "limit",
+ "-override=": "override",
+ }
+
+ for arg_pattern, field_name in arg_mappings.items():
+ if arg.startswith(arg_pattern):
+ form_values[field_name] = arg.split("=", 1)[1]
+ return
+
+
+def parse_gascan_task_args(meta: dict[str, Any]) -> dict[str, Any]:
+ """Parse existing task arguments back into form field values.
+
+ :param meta: The task meta containing the args string.
+ :type meta: dict[str, Any]
+ :return: A dictionary containing form field values.
+ :rtype: dict[str, Any]
+ """
+ form_values = {
+ "playbook": "",
+ "limit": "",
+ "override": "",
+ }
+
+ args_string = meta.get("args", "")
+ for arg in shlex.split(args_string):
+ parse_single_gascan_arg(arg, form_values)
+
+ return form_values
+
+
+async def get_gascan_index_context(
+ inventory_api: InventoryAPI,
+ tasks_api: TaskAPI,
+ context: DefaultContext,
+ executor_hosts_ctx: ExecutorHostsCtx,
+) -> dict[str, Any]:
+ """Assemble the context for the Gascan plugin index view.
+
+ :param inventory_api: The Inventory API client (unused for service lookup).
+ :type inventory_api: InventoryAPI
+ :param tasks_api: The TaskAPI client for fetching task data.
+ :type tasks_api: TaskAPI
+ :param context: The default context to be updated.
+ :type context: DefaultContext
+ :param executor_hosts_ctx: The executor hosts context for gascan tasks.
+ :type executor_hosts_ctx: ExecutorHostsCtx
+ :return: An updated context dictionary containing gascan-related data.
+ :rtype: dict[str, Any]
+ """
+ return await get_tasks_context(
+ inventory_api,
+ tasks_api,
+ get_gascan_task_info,
+ executor_hosts_ctx,
+ context,
+ TaskOwner.GASCAN,
+ alert_on_fail_default=True,
+ )
diff --git a/app/sep/plugins/gascan/models.py b/app/sep/plugins/gascan/models.py
new file mode 100644
index 0000000000..825df23fd7
--- /dev/null
+++ b/app/sep/plugins/gascan/models.py
@@ -0,0 +1,124 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define models for the Gascan plugin."""
+
+from datetime import datetime
+from typing import Any
+
+from pydantic import BaseModel
+
+from app.core.utils.fields import NonEmptyStr
+from app.tasks.models import TaskBackendEnum, TaskHistoryStatusEnum, TaskOwner
+
+
+class GascanCreate(BaseModel):
+ """Represent a Gascan creation form.
+
+ :param task_name: The name of the task to be created.
+ :type task_name: NonEmptyStr
+ :param hostname: The target hostname for the task execution.
+ :type hostname: NonEmptyStr
+ :param playbook: The playbook to run.
+ :type playbook: NonEmptyStr
+ :param limit: Optional limit expression for the playbook run.
+ :type limit: str
+ :param override: Optional override values for the playbook run.
+ :type override: str
+ :param alert_on_fail: If True, send an alert if the task fails.
+ :type alert_on_fail: bool
+ """
+
+ task_name: NonEmptyStr
+ hostname: NonEmptyStr
+ playbook: NonEmptyStr
+ limit: str = ""
+ override: str = ""
+ alert_on_fail: bool = False
+
+
+class GascanTaskWrite(BaseModel):
+ """Represent a JSON request body for creating a gascan task.
+
+ :param task_name: The name of the task to be created.
+ :type task_name: NonEmptyStr
+ :param hostname: The target hostname for the task execution.
+ :type hostname: NonEmptyStr
+ :param playbook: The playbook to run.
+ :type playbook: NonEmptyStr
+ :param limit: Optional limit expression for the playbook run.
+ :type limit: str
+ :param override: Optional override values for the playbook run.
+ :type override: str
+ :param alert_on_fail: If True, send an alert if the task fails.
+ :type alert_on_fail: bool
+ """
+
+ task_name: NonEmptyStr
+ hostname: NonEmptyStr
+ playbook: NonEmptyStr
+ limit: str = ""
+ override: str = ""
+ alert_on_fail: bool = False
+
+
+class GascanTaskBase(BaseModel):
+ """Define the common fields shared across gascan task API responses.
+
+ :param name: The name of the gascan task.
+ :type name: str
+ :param owner: The entity or user that owns the task.
+ :type owner: TaskOwner
+ :param status: The current execution status of the task.
+ :type status: TaskHistoryStatusEnum | None
+ """
+
+ name: str
+ owner: TaskOwner
+ status: TaskHistoryStatusEnum | None = None
+
+
+class GascanTaskResponse(GascanTaskBase):
+ """Represent a gascan task API response.
+
+ :param id: The unique identifier for the gascan task.
+ :type id: int | None
+ :param backend: The backend worker/engine executing the task.
+ :type backend: TaskBackendEnum
+ :param data: The raw configuration and parameters used for execution.
+ :type data: dict[str, Any]
+ :param protected: Whether the task is protected from deletion or modification.
+ :type protected: bool
+ :param alert_on_fail: If True, notifications are sent upon task failure.
+ :type alert_on_fail: bool
+ :param created_at: The timestamp when the task was first created.
+ :type created_at: datetime | None
+ :param updated_at: The timestamp of the last modification to the task record.
+ :type updated_at: datetime | None
+ :param created_by: The user who initiated the task.
+ :type created_by: str | None
+ :param last_updated_by: The user who last modified the task record.
+ :type last_updated_by: str | None
+ """
+
+ id: int | None = None
+ backend: TaskBackendEnum
+ data: dict[str, Any]
+ protected: bool
+ alert_on_fail: bool
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+ created_by: str | None = None
+ last_updated_by: str | None = None
diff --git a/app/sep/plugins/gascan/routes.py b/app/sep/plugins/gascan/routes.py
new file mode 100644
index 0000000000..1aeae1f8ae
--- /dev/null
+++ b/app/sep/plugins/gascan/routes.py
@@ -0,0 +1,196 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define routes for the gascan plugin."""
+
+import logging
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, Form, Request, status
+from fastapi.responses import HTMLResponse, RedirectResponse
+from pydantic import FutureDatetime
+
+from app.core.alerts.config import alert_settings
+from app.sep.config import sep_settings
+from app.sep.deps import (
+ DefaultContext,
+ ExecutorHostsCtx,
+ get_chainable_tasks,
+ HasNoConflictedRunningTasks,
+ IsAuthenticated,
+ IsCsrfValidated,
+ TaskAPI,
+)
+from app.sep.plugins.gascan.deps import (
+ GascanGeneratedTask,
+ GascanTask,
+ get_gascan_index_context,
+ parse_gascan_task_args,
+)
+from app.tasks.models import TaskHistoryStatusEnum
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+templates = sep_settings.TEMPLATES
+
+
+@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse)
+async def gascan_index(
+ request: Request,
+ context: Annotated[dict[str, Any], Depends(get_gascan_index_context)],
+) -> HTMLResponse:
+ """Homepage of the gascan plugin."""
+ context["csrf_token"] = request.state.csrf_token
+ return templates.TemplateResponse(
+ request=request,
+ name="gascan/index.html.j2",
+ context=context,
+ )
+
+
+@router.post(
+ "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse
+)
+async def gascan_create(
+ request: Request,
+ task: GascanGeneratedTask,
+ task_api: TaskAPI,
+) -> RedirectResponse:
+ """Create a gascan task."""
+ logger.debug("Create gascan task: %s", task)
+ await task_api.post(
+ "/",
+ json=task.model_dump(),
+ )
+
+ task_path = request.url_for("gascan_detail", task_name=task.name)
+ return RedirectResponse(
+ task_path,
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+
+@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse)
+async def gascan_detail(
+ task: GascanTask,
+ request: Request,
+ context: DefaultContext,
+ tasks_api: TaskAPI,
+ executor_hosts_ctx: ExecutorHostsCtx,
+) -> HTMLResponse:
+ """Retrieve a gascan task."""
+ data = task.data
+ meta = data["meta"]
+ decoded_entities = task.anonymized_entities
+ task_data = {
+ "name": task.name,
+ "created_at": task.created_at,
+ "updated_at": task.updated_at,
+ "created_by": task.created_by,
+ "last_updated_by": task.last_updated_by,
+ "hostname": meta["target"],
+ "cmd": f"{meta['command']} {meta['args']}",
+ "meta": meta,
+ "entities": {entity.name: entity.value for entity in decoded_entities},
+ "delete_url": request.url_for("gascan_delete", task_name=task.name),
+ "alert_on_fail": task.alert_on_fail,
+ "is_edit_enabled": not task.protected,
+ }
+ task_data.update(parse_gascan_task_args(meta))
+
+ context["task"] = task_data
+ context["executor_hosts"] = executor_hosts_ctx.with_host(
+ task_data["hostname"]
+ ).as_template_list()
+
+ response = await tasks_api.get(f"/{task.name}/history/")
+ context["history"] = response["items"]
+ response = await tasks_api.get(
+ f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING}
+ )
+ context["running_tasks"] = response["items"]
+ context["stats"] = await tasks_api.get(f"/stats/{task.name}")
+ context["alert_on_fail_default"] = task_data["alert_on_fail"]
+ context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS)
+ context["chainable_tasks"] = await get_chainable_tasks(
+ tasks_api, task.owner, meta["target"], task.name
+ )
+ return templates.TemplateResponse(
+ request=request,
+ name="gascan/details.html.j2",
+ context=context,
+ )
+
+
+@router.post(
+ "/{task_name}",
+ dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks],
+ response_class=RedirectResponse,
+)
+async def gascan_execute(
+ task: GascanTask,
+ tasks_api: TaskAPI,
+ eta: Annotated[FutureDatetime | None, Form()] = None,
+ chain_task_names: Annotated[list[str] | None, Form()] = None,
+ chain_on_failure: Annotated[bool | None, Form()] = None,
+) -> RedirectResponse:
+ """Execute a gascan task."""
+ await tasks_api.post(
+ f"/execute/{task.name}",
+ json={
+ "eta": eta,
+ "chain_task_names": chain_task_names,
+ "chain_on_failure": chain_on_failure,
+ },
+ )
+ return RedirectResponse("/gascan", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.post(
+ "/{task_name}/update",
+ dependencies=[IsAuthenticated, IsCsrfValidated],
+ response_class=RedirectResponse,
+)
+async def gascan_update(
+ request: Request,
+ task_name: str,
+ updated_task: GascanGeneratedTask,
+ tasks_api: TaskAPI,
+) -> RedirectResponse:
+ """Update a gascan task."""
+ logger.debug("Updating gascan task: %s", updated_task)
+ await tasks_api.put(
+ f"/{task_name}",
+ json=updated_task.model_dump(),
+ )
+
+ return RedirectResponse(
+ request.url_for("gascan_detail", task_name=updated_task.name),
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+
+@router.post(
+ "/{task_name}/delete",
+ dependencies=[IsAuthenticated, IsCsrfValidated],
+ response_class=RedirectResponse,
+)
+async def gascan_delete(
+ task: GascanTask,
+ tasks_api: TaskAPI,
+) -> RedirectResponse:
+ """Delete a gascan task."""
+ await tasks_api.delete(f"/{task.name}")
+ return RedirectResponse("/gascan", status_code=status.HTTP_303_SEE_OTHER)
diff --git a/app/sep/plugins/gascan/schema.py b/app/sep/plugins/gascan/schema.py
new file mode 100644
index 0000000000..48e47926da
--- /dev/null
+++ b/app/sep/plugins/gascan/schema.py
@@ -0,0 +1,80 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define the PluginSchema for the Gascan plugin."""
+
+from app.sep.plugins.framework.schema import (
+ Capabilities,
+ Column,
+ ColumnFormat,
+ FormSection,
+ HostField,
+ ListView,
+ PluginSchema,
+ StringField,
+)
+
+gascan_schema = PluginSchema(
+ name="gascan",
+ display_name="Gascan Management",
+ description="Run gascan playbooks on executor hosts.",
+ forms=[
+ FormSection(
+ title="Task",
+ fields=[
+ StringField(
+ name="task_name",
+ label="Task Name",
+ required=True,
+ ),
+ HostField(
+ name="hostname",
+ label="Executor Host",
+ required=True,
+ ),
+ ],
+ ),
+ FormSection(
+ title="Gascan",
+ fields=[
+ StringField(
+ name="playbook",
+ label="Playbook",
+ required=True,
+ description="Playbook to execute",
+ ),
+ StringField(
+ name="limit",
+ label="Limit",
+ description="Optional limit expression",
+ ),
+ StringField(
+ name="override",
+ label="Override",
+ description="Optional override values",
+ ),
+ ],
+ ),
+ ],
+ capabilities=Capabilities(chaining=True, alert_on_fail=True, scheduling=True),
+ list_view=ListView(
+ columns=[
+ Column(key="name", label="Name", sortable=True),
+ Column(key="status", label="Status", format=ColumnFormat.STATUS),
+ Column(key="created_at", label="Created", format=ColumnFormat.RELATIVE),
+ Column(key="created_by", label="Created By"),
+ ],
+ ),
+)
diff --git a/app/tasks/models.py b/app/tasks/models.py
index 89fe8bc6d5..0379a7e7d8 100644
--- a/app/tasks/models.py
+++ b/app/tasks/models.py
@@ -194,6 +194,8 @@ class TaskOwner(EnumFieldMixin, StrEnum):
:vartype RESTORES: str
:cvar CHECKSUMS: Value for checksum tasks.
:vartype CHECKSUMS: str
+ :cvar GASCAN: Value for gascan management tasks.
+ :vartype GASCAN: str
"""
ANY = "ANY"
@@ -202,6 +204,7 @@ class TaskOwner(EnumFieldMixin, StrEnum):
BACKUPS = "BACKUPS"
RESTORES = "RESTORES"
CHECKSUMS = "CHECKSUMS"
+ GASCAN = "GASCAN"
BACKUP_MONGO = "BACKUP_MONGO"
RESTORE_MONGO = "RESTORE_MONGO"
BACKUP_PG = "BACKUP_PG"
diff --git a/frontend/package.json b/frontend/package.json
index 0d4f26e86c..502bdb74c0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,6 +21,7 @@
"build-storybook": "pnpm --filter @sep/framework build-storybook"
},
"devDependencies": {
+ "nanoid": "3.3.12",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0"
},
diff --git a/frontend/packages/plugins/gascan/package.json b/frontend/packages/plugins/gascan/package.json
new file mode 100644
index 0000000000..8d3cc1b418
--- /dev/null
+++ b/frontend/packages/plugins/gascan/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@sep/plugin-gascan",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@sep/api": "workspace:*",
+ "@sep/framework": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.0",
+ "typescript": "~6.0.3"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0",
+ "react-router-dom": "^7.0.0"
+ }
+}
diff --git a/frontend/packages/plugins/gascan/src/GascanPlugin.tsx b/frontend/packages/plugins/gascan/src/GascanPlugin.tsx
new file mode 100644
index 0000000000..16c768dd4e
--- /dev/null
+++ b/frontend/packages/plugins/gascan/src/GascanPlugin.tsx
@@ -0,0 +1,23 @@
+/**
+ * Copyright (C) 2026 Percona LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { SchemaDrivenPlugin } from '@sep/framework';
+import { PLUGIN_NAME } from './routes';
+
+export function GascanPlugin() {
+ return ;
+}
diff --git a/frontend/packages/plugins/gascan/src/index.ts b/frontend/packages/plugins/gascan/src/index.ts
new file mode 100644
index 0000000000..943463e8ee
--- /dev/null
+++ b/frontend/packages/plugins/gascan/src/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Copyright (C) 2026 Percona LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+export { GascanPlugin } from './GascanPlugin';
+export { gascanRoute, PLUGIN_NAME, PLUGIN_BASE_PATH } from './routes';
diff --git a/frontend/packages/plugins/gascan/src/routes.tsx b/frontend/packages/plugins/gascan/src/routes.tsx
new file mode 100644
index 0000000000..9fd7d9df61
--- /dev/null
+++ b/frontend/packages/plugins/gascan/src/routes.tsx
@@ -0,0 +1,27 @@
+/**
+ * Copyright (C) 2026 Percona LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { RouteObject } from 'react-router-dom';
+import { GascanPlugin } from './GascanPlugin';
+
+export const PLUGIN_NAME = 'gascan';
+export const PLUGIN_BASE_PATH = '/plugins/gascan';
+
+export const gascanRoute: RouteObject = {
+ path: 'plugins/gascan/*',
+ element: ,
+};
diff --git a/frontend/packages/plugins/gascan/tsconfig.json b/frontend/packages/plugins/gascan/tsconfig.json
new file mode 100644
index 0000000000..3b79150329
--- /dev/null
+++ b/frontend/packages/plugins/gascan/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "include": ["./src"]
+}
diff --git a/frontend/packages/shell/package.json b/frontend/packages/shell/package.json
index 3be308521a..0a546e6425 100644
--- a/frontend/packages/shell/package.json
+++ b/frontend/packages/shell/package.json
@@ -29,6 +29,7 @@
"@sep/plugin-atw": "workspace:*",
"@sep/plugin-checksums": "workspace:*",
"@sep/plugin-dipper": "workspace:*",
+ "@sep/plugin-gascan": "workspace:*",
"@sep/plugins-snippets": "workspace:*",
"@sep/shared": "workspace:*",
"@tanstack/react-query": "^5.100.7",
diff --git a/frontend/packages/shell/src/contexts/navigation.tsx b/frontend/packages/shell/src/contexts/navigation.tsx
index 70efd2e18f..deb701d512 100644
--- a/frontend/packages/shell/src/contexts/navigation.tsx
+++ b/frontend/packages/shell/src/contexts/navigation.tsx
@@ -31,6 +31,7 @@ import ArchiveIcon from '@mui/icons-material/Archive';
import BarChartIcon from '@mui/icons-material/BarChart';
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
import ScienceIcon from '@mui/icons-material/Science';
+import BuildIcon from '@mui/icons-material/Build';
import { MySqlIcon, MongoIcon, PostgreSqlIcon } from '@percona/percona-ui';
import type { SvgIconComponent } from '@mui/icons-material';
import type { SvgIconProps } from '@mui/material';
@@ -64,6 +65,7 @@ const defaultNavItems: NavItem[] = [
children: [{ title: 'Alters', icon: TableChartIcon, to: '/schema-change/alters' }],
},
{ title: 'Checksums', icon: CheckCircleIcon, to: '/plugins/checksums' },
+ { title: 'Gascan', icon: BuildIcon, to: '/plugins/gascan' },
{
title: 'Backups',
icon: BackupIcon,
diff --git a/frontend/packages/shell/src/router.tsx b/frontend/packages/shell/src/router.tsx
index c19e1e73e8..b985fdb1d2 100644
--- a/frontend/packages/shell/src/router.tsx
+++ b/frontend/packages/shell/src/router.tsx
@@ -31,6 +31,9 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const ChecksumsPlugin = lazy(() =>
import('@sep/plugin-checksums').then((m) => ({ default: m.ChecksumsPlugin })),
);
+const GascanPlugin = lazy(() =>
+ import('@sep/plugin-gascan').then((m) => ({ default: m.GascanPlugin })),
+);
const AtwPlugin = lazy(() => import('@sep/plugin-atw').then((m) => ({ default: m.AtwPlugin })));
const DipperPlugin = lazy(() =>
import('@sep/plugin-dipper').then((m) => ({ default: m.DipperPlugin })),
@@ -80,6 +83,7 @@ export const router = createBrowserRouter([
// Checksums — schema-driven plugin (handles its own sub-routes)
{ path: 'plugins/checksums/*', element: },
{ path: 'schema-change/checksums/*', element: },
+ { path: 'plugins/gascan/*', element: },
{ path: 'schema-change/inventory/*', element: },
{ path: 'backups/mysql', element: },
{ path: 'backups/mongodb', element: },
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 62d0d289c3..3b136c7398 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
devDependencies:
+ nanoid:
+ specifier: 3.3.12
+ version: 3.3.12
oxfmt:
specifier: ^0.49.0
version: 0.49.0
@@ -363,6 +366,28 @@ importers:
specifier: ^4.0.0
version: 4.1.5(@types/node@22.19.17)(jsdom@29.1.1)(msw@2.14.4(@types/node@22.19.17)(typescript@6.0.3))(vite@8.0.12(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0))
+ packages/plugins/gascan:
+ dependencies:
+ '@sep/api':
+ specifier: workspace:*
+ version: link:../../api
+ '@sep/framework':
+ specifier: workspace:*
+ version: link:../../framework
+ react:
+ specifier: ^19.0.0
+ version: 19.2.6
+ react-router-dom:
+ specifier: ^7.0.0
+ version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.1.0
+ version: 19.2.14
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+
packages/plugins/inventory:
dependencies:
'@mui/material':
@@ -538,6 +563,9 @@ importers:
'@sep/plugin-dipper':
specifier: workspace:*
version: link:../plugins/dipper
+ '@sep/plugin-gascan':
+ specifier: workspace:*
+ version: link:../plugins/gascan
'@sep/plugins-snippets':
specifier: workspace:*
version: link:../plugins/snippets
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
index 8c726d1f11..d76b91d0e9 100644
--- a/frontend/pnpm-workspace.yaml
+++ b/frontend/pnpm-workspace.yaml
@@ -9,3 +9,9 @@ minimumReleaseAge: 10080
# Activates on pnpm v11+ (silently ignored on 10.x). Keeps fail-closed intent
# discoverable once the workspace bumps the pnpm pin.
minimumReleaseAgeIgnoreMissingTime: false
+# Dev-tooling releases frequently; exclude from the 7-day cooldown (see oxc-project).
+minimumReleaseAgeExclude:
+ - '@oxlint/*'
+ - oxlint
+ - '@oxfmt/*'
+ - oxfmt
diff --git a/settings.yaml b/settings.yaml
index 3f427ef67b..ab70d5ec26 100644
--- a/settings.yaml
+++ b/settings.yaml
@@ -83,6 +83,10 @@ default:
MODULE_NAME: checksums
URI_PATH: /checksums
CSS_CLASS: checksums
+ - NAME: Gascan
+ MODULE_NAME: gascan
+ URI_PATH: /gascan
+ CSS_CLASS: gascan
- NAME: MongoDB Backups
MODULE_NAME: backup_mongo
URI_PATH: /backup_mongo
diff --git a/static/css/base.css b/static/css/base.css
index e0347682a2..08ebfd0fd4 100644
--- a/static/css/base.css
+++ b/static/css/base.css
@@ -1250,6 +1250,10 @@ button.list-item:focus-visible {
content: "new_releases";
}
+.list-item.gascan .icon::before {
+ content: "build";
+}
+
.list-item.backup_mongo .icon::before {
content: "nature";
}
diff --git a/templates/gascan/details.html.j2 b/templates/gascan/details.html.j2
new file mode 100644
index 0000000000..7b793c903b
--- /dev/null
+++ b/templates/gascan/details.html.j2
@@ -0,0 +1,165 @@
+{#
+Copyright (C) 2026 Percona LLC
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+#}
+{% extends "base.html.j2" %}
+{% block title -%}
+ Gascan - {{ task.name }}
+{%- endblock title %}
+{% block style %}
+
+
+ {% if history or running_tasks %}
+
+ {% endif %}
+
+{% endblock style %}
+{% block behaviours_optimized_extra %}
+
+
+ {% if history or running_tasks %}
+
+ {% endif %}
+
+{% endblock behaviours_optimized_extra %}
+{% block main %}
+
Overview
+
+
+
+
Hostname
+
Playbook
+
Limit
+
Override
+
Created at
+
Updated at
+
Created by
+
Last Updated by
+
+
+
+
+
{{ task.hostname }}
+
{{ task.playbook }}
+
{{ task.limit }}
+
{{ task.override }}
+
{{ task.created_at }}
+
{{ task.updated_at }}
+
{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}
+
{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
+
+
+
+ {% include "tasks/partials/run-python-form.html.j2" %}
+ {% include "gascan/partials/edit-form.html.j2" %}
+ {% if running_tasks %}
+
+
+ {% set history_tasks = history %}
+ {% include "tasks/partials/completed-tasks.html.j2" %}
+
+{% endblock main %}
diff --git a/templates/gascan/index.html.j2 b/templates/gascan/index.html.j2
new file mode 100644
index 0000000000..b3541873b9
--- /dev/null
+++ b/templates/gascan/index.html.j2
@@ -0,0 +1,114 @@
+{#
+Copyright (C) 2026 Percona LLC
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+#}
+{% extends "base.html.j2" %}
+{% block title -%}
+ Gascan Management
+{%- endblock title %}
+{% block style %}
+
+ {% if history_tasks or running_tasks %}
+
+ {% endif %}
+ {% if tasks %}
+
+
+
+
+ {% endif %}
+{% endblock style %}
+{% block behaviours_optimized_extra %}
+
+ {% if history_tasks or running_tasks %}
+
+ {% endif %}
+ {% if tasks %}
+
+
+
+
+
+
+
+ {% endif %}
+{% endblock behaviours_optimized_extra %}
+{% block main %}
+
Gascan Management
+
Run gascan playbooks on executor hosts.
+ {% if not tasks and not history_tasks %}
+ {% set modal_required = False %}
+ {% if executor_hosts|length < 1 %}
+
+ {% include "gascan/partials/saved-tasks.html.j2" %}
+
Periodic Tasks
+ {%- set detail_route = "gascan_detail" %}
+ {% include "tasks/partials/scheduled-tasks.html.j2" %}
+
Configure a gascan task
+ {% include "gascan/partials/create-form.html.j2" %}
+
+
+
+
+
Pending
+ {% include "tasks/partials/pending-tasks.html.j2" %}
+
+
+
+
+
History
+ {% include "tasks/partials/completed-tasks.html.j2" %}
+
+ {% endif %}
+ {% if executor_hosts|length >= 1 %}
+ {% include "gascan/partials/create-form.html.j2" %}
+ {% endif %}
+{% endblock main %}
diff --git a/templates/gascan/partials/create-form.html.j2 b/templates/gascan/partials/create-form.html.j2
new file mode 100644
index 0000000000..9be5b8118b
--- /dev/null
+++ b/templates/gascan/partials/create-form.html.j2
@@ -0,0 +1,121 @@
+{#
+Copyright (C) 2026 Percona LLC
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+#}
+
+
diff --git a/templates/gascan/partials/edit-form.html.j2 b/templates/gascan/partials/edit-form.html.j2
new file mode 100644
index 0000000000..6df34f69d7
--- /dev/null
+++ b/templates/gascan/partials/edit-form.html.j2
@@ -0,0 +1,131 @@
+{#
+Copyright (C) 2026 Percona LLC
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+#}
+
+
+{% if task.is_edit_enabled %}
+
+{% endif %}
diff --git a/templates/gascan/partials/saved-tasks.html.j2 b/templates/gascan/partials/saved-tasks.html.j2
new file mode 100644
index 0000000000..7137dbffc7
--- /dev/null
+++ b/templates/gascan/partials/saved-tasks.html.j2
@@ -0,0 +1,49 @@
+{#
+Copyright (C) 2026 Percona LLC
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+#}
+{% if tasks %}
+
{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}
+
{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
+
{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
+
+ {% endfor %}
+
+
+ {% include "tasks/partials/scheduled-execution-dialog.html.j2" %}
+{% else %}
+
No data
+{% endif %}
+
+
diff --git a/tests/app/sep/plugins/gascan/__init__.py b/tests/app/sep/plugins/gascan/__init__.py
new file mode 100644
index 0000000000..bc39b9ba8a
--- /dev/null
+++ b/tests/app/sep/plugins/gascan/__init__.py
@@ -0,0 +1,14 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
diff --git a/tests/app/sep/plugins/gascan/conftest.py b/tests/app/sep/plugins/gascan/conftest.py
new file mode 100644
index 0000000000..bb01d3a15b
--- /dev/null
+++ b/tests/app/sep/plugins/gascan/conftest.py
@@ -0,0 +1,21 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define test fixtures for gascan plugin route tests."""
+
+from tests.app.sep.conftest import ( # noqa: F401
+ mock_inventory_api_dep,
+ mock_task_api_dep,
+)
diff --git a/tests/app/sep/plugins/gascan/test_api_routes.py b/tests/app/sep/plugins/gascan/test_api_routes.py
new file mode 100644
index 0000000000..0cc17d9dd0
--- /dev/null
+++ b/tests/app/sep/plugins/gascan/test_api_routes.py
@@ -0,0 +1,153 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Tests for the gascan plugin JSON API routes under /api/plugins/gascan/."""
+
+import shlex
+from datetime import datetime, UTC
+from unittest.mock import AsyncMock, call
+
+from fastapi import status
+
+from app.tasks.models import TaskBackendEnum, TaskHistoryStatusEnum, TaskOwner
+
+
+def build_gascan_task(name: str = "gascan-task") -> dict:
+ """Build a fake gascan task payload for route tests."""
+ return {
+ "id": 1,
+ "name": name,
+ "backend": TaskBackendEnum.PROXY,
+ "owner": TaskOwner.GASCAN,
+ "is_template": False,
+ "protected": False,
+ "alert_on_fail": False,
+ "data": {
+ "task": "run-command",
+ "meta": {
+ "command": "gascan",
+ "args": "-playbook=site.yml -limit=web",
+ "target": "host1",
+ },
+ },
+ "created_at": datetime.now(UTC),
+ "updated_at": None,
+ "created_by": "user@example.com",
+ "last_updated_by": "user@example.com",
+ }
+
+
+def build_gascan_write_body(task_name: str = "gascan-task", **kwargs) -> dict:
+ """Build a valid GascanTaskWrite-compatible request body."""
+ return {
+ "task_name": task_name,
+ "hostname": "host1",
+ "playbook": "site.yml",
+ **kwargs,
+ }
+
+
+class TestGascanSchemaEndpoint:
+ """Tests for GET /api/plugins/gascan/schema."""
+
+ def test_gascan_schema_returns_plugin_metadata(self, test_client):
+ """Assert schema endpoint exposes gascan plugin name and fields."""
+ response = test_client.get("/api/plugins/gascan/schema")
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert data["name"] == "gascan"
+ assert data["display_name"] == "Gascan Management"
+ field_names = {
+ field["name"] for section in data["forms"] for field in section["fields"]
+ }
+ assert {"task_name", "hostname", "playbook", "limit", "override"} <= field_names
+
+
+class TestGascanListEndpoint:
+ """Tests for GET /api/plugins/gascan/."""
+
+ def test_gascan_list_returns_data(self, test_client, mock_task_api_dep):
+ """Assert list endpoint returns gascan tasks filtered by owner."""
+ task = build_gascan_task()
+ mock_task_api_dep.get = AsyncMock(
+ side_effect=[
+ {"items": [task]},
+ {"items": []},
+ ]
+ )
+
+ response = test_client.get("/api/plugins/gascan/")
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()) == 1
+ assert response.json()[0]["name"] == "gascan-task"
+ mock_task_api_dep.get.assert_any_call(
+ "/",
+ params={"owner": TaskOwner.GASCAN.value},
+ )
+
+
+class TestGascanCreateEndpoint:
+ """Tests for POST /api/plugins/gascan/."""
+
+ def test_gascan_create_posts_assembled_payload(
+ self, test_client, mock_task_api_dep
+ ):
+ """Assert create endpoint builds gascan command args correctly."""
+ created = build_gascan_task("new-gascan")
+ mock_task_api_dep.post = AsyncMock(return_value=created)
+
+ body = build_gascan_write_body(
+ task_name="new-gascan",
+ limit="db",
+ override="x=1",
+ )
+ response = test_client.post("/api/plugins/gascan/", json=body)
+ assert response.status_code == status.HTTP_201_CREATED
+
+ posted = mock_task_api_dep.post.await_args.kwargs["json"]
+ assert posted["owner"] == TaskOwner.GASCAN.value
+ args = shlex.split(posted["data"]["meta"]["args"])
+ assert "-playbook=site.yml" in args
+ assert "-limit=db" in args
+ assert "-override=x=1" in args
+
+
+class TestGascanDetailAndDeleteEndpoints:
+ """Tests for GET and DELETE /api/plugins/gascan/{task_name}."""
+
+ def test_gascan_detail_returns_task(self, test_client, mock_task_api_dep):
+ """Assert detail endpoint returns a single gascan task."""
+ task = build_gascan_task("detail-task")
+ mock_task_api_dep.get = AsyncMock(
+ side_effect=[
+ task,
+ {"items": [{"status": TaskHistoryStatusEnum.SUCCESS}]},
+ ]
+ )
+
+ response = test_client.get("/api/plugins/gascan/detail-task")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["name"] == "detail-task"
+ assert mock_task_api_dep.get.await_args_list[0] == call("/detail-task")
+
+ def test_gascan_delete_removes_task(self, test_client, mock_task_api_dep):
+ """Assert delete endpoint removes the task via Tasks API."""
+ task = build_gascan_task("delete-me")
+ mock_task_api_dep.get = AsyncMock(return_value=task)
+ mock_task_api_dep.delete = AsyncMock()
+
+ response = test_client.delete("/api/plugins/gascan/delete-me")
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ mock_task_api_dep.delete.assert_awaited_once_with("/delete-me")
diff --git a/tests/app/sep/plugins/gascan/test_deps.py b/tests/app/sep/plugins/gascan/test_deps.py
new file mode 100644
index 0000000000..6cd14874c5
--- /dev/null
+++ b/tests/app/sep/plugins/gascan/test_deps.py
@@ -0,0 +1,97 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define tests for the app.sep.plugins.gascan.deps module."""
+
+import shlex
+
+import pytest
+
+from app.sep.plugins.gascan.deps import (
+ _assemble_gascan_payload,
+ build_gascan_task,
+ parse_gascan_task_args,
+)
+from app.sep.plugins.gascan.models import GascanCreate, GascanTaskWrite
+from app.tasks.models import TaskOwner, TaskWrite
+
+
+class TestGascanPayloadAssembly:
+ """Tests for gascan task payload assembly."""
+
+ def test_assemble_gascan_payload_full_args(self):
+ """Assert all gascan CLI flags are included when values are provided."""
+ payload = _assemble_gascan_payload(
+ task_name="gascan-test",
+ hostname="executor1",
+ playbook="backup.yml",
+ limit="web",
+ override="env=prod",
+ alert_on_fail=True,
+ )
+
+ assert payload.owner == TaskOwner.GASCAN
+ assert payload.name == "gascan-test"
+ assert payload.target == "executor1"
+ assert payload.alert_on_fail is True
+ meta = payload.data["meta"]
+ assert meta["command"] == "gascan"
+ assert meta["target"] == "executor1"
+ args = shlex.split(meta["args"])
+ assert "-playbook=backup.yml" in args
+ assert "-limit=web" in args
+ assert "-override=env=prod" in args
+
+ def test_assemble_gascan_payload_omits_empty_optional_args(self):
+ """Assert empty limit and override are not passed to the command."""
+ payload = _assemble_gascan_payload(
+ task_name="minimal",
+ hostname="host1",
+ playbook="run.yml",
+ )
+ args = shlex.split(payload.data["meta"]["args"])
+ assert args == ["-playbook=run.yml"]
+
+ @pytest.mark.asyncio
+ async def test_form_and_json_paths_produce_identical_task_write(self):
+ """Assert HTML form and JSON builders produce the same TaskWrite."""
+ common_fields = {
+ "task_name": "parity-check",
+ "hostname": "host1",
+ "playbook": "deploy.yml",
+ "limit": "db",
+ "override": "dry_run=false",
+ "alert_on_fail": True,
+ }
+ form_input = GascanCreate(**common_fields)
+ json_input = GascanTaskWrite(**common_fields)
+
+ from app.sep.plugins.gascan.deps import build_gascan_task_payload
+
+ form_result = await build_gascan_task_payload(form_input)
+ json_result = build_gascan_task(json_input)
+
+ assert isinstance(form_result, TaskWrite)
+ assert form_result.model_dump() == json_result.model_dump()
+
+ def test_parse_gascan_task_args_round_trip(self):
+ """Assert parse_gascan_task_args recovers form values from meta args."""
+ meta = {
+ "args": "-playbook=backup.yml -limit=web -override=env=prod",
+ }
+ parsed = parse_gascan_task_args(meta)
+ assert parsed["playbook"] == "backup.yml"
+ assert parsed["limit"] == "web"
+ assert parsed["override"] == "env=prod"
diff --git a/tests/app/sep/plugins/gascan/test_routes.py b/tests/app/sep/plugins/gascan/test_routes.py
new file mode 100644
index 0000000000..992be12c66
--- /dev/null
+++ b/tests/app/sep/plugins/gascan/test_routes.py
@@ -0,0 +1,51 @@
+# Copyright (C) 2026 Percona LLC
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Define tests for the app.sep.plugins.gascan.routes module."""
+
+from unittest.mock import AsyncMock
+
+from fastapi import status
+
+from app.sep.plugins.gascan.models import GascanCreate
+from app.tasks.models import TaskOwner
+
+
+def test_gascan_create_posts_task_with_gascan_owner(
+ test_client,
+ mock_task_api_dep,
+):
+ """Test POST /gascan/ creates a task owned by GASCAN."""
+ gascan_create = GascanCreate(
+ task_name="gascan_full_chain",
+ hostname="localhost",
+ playbook="site.yml",
+ limit="all",
+ override="foo=bar",
+ )
+ mock_task_api_dep.post.return_value = AsyncMock()
+
+ response = test_client.post(
+ "/gascan/",
+ data=gascan_create.model_dump(exclude_none=True),
+ follow_redirects=False,
+ )
+ assert response.status_code == status.HTTP_303_SEE_OTHER
+ assert response.headers["location"].endswith("/gascan/gascan_full_chain")
+ mock_task_api_dep.post.assert_awaited_once()
+ posted = mock_task_api_dep.post.await_args.kwargs["json"]
+ assert posted["name"] == "gascan_full_chain"
+ assert posted["owner"] == TaskOwner.GASCAN.value
+ assert posted["data"]["meta"]["command"] == "gascan"