feat(scripts): каталог bash-скриптов для нод и гостей (VM/LXC)
Добавлена библиотека скриптов по образцу VMmanager: свои скрипты (CRUD в UI) и синхронизация из git-репозитория, параметризация через env-переменные (без конкатенации в shell), выполнение на нодах Proxmox (SSH), в LXC (pct exec) и в VM (QEMU guest agent, переиспользует существующий execute_script). Журнал запусков встроен в общий список задач /api/all-tasks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import httpx
|
||||
import websockets
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask, DeployTask, AppOperation, InstalledApp
|
||||
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask, DeployTask, AppOperation, InstalledApp, ScriptExecution
|
||||
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
|
||||
from ...proxmox import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
@@ -213,6 +213,7 @@ def get_all_tasks(
|
||||
proxmox_q = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == current_user.id)
|
||||
deploy_q = db.query(DeployTask).filter(DeployTask.user_id == current_user.id)
|
||||
appstore_q = db.query(AppOperation).filter(AppOperation.user_id == current_user.id)
|
||||
script_q = db.query(ScriptExecution).filter(ScriptExecution.user_id == current_user.id)
|
||||
|
||||
if status_filter:
|
||||
# Normalise statuses: proxmox uses running/completed/failed; bulk uses the same + pending/cancelled
|
||||
@@ -220,11 +221,13 @@ def get_all_tasks(
|
||||
proxmox_q = proxmox_q.filter(ProxmoxTask.status == status_filter)
|
||||
deploy_q = deploy_q.filter(DeployTask.status == status_filter)
|
||||
appstore_q = appstore_q.filter(AppOperation.status == status_filter)
|
||||
script_q = script_q.filter(ScriptExecution.status == status_filter)
|
||||
|
||||
bulk_tasks = bulk_q.order_by(TaskQueue.created_at.desc()).limit(limit).all()
|
||||
proxmox_tasks = proxmox_q.order_by(ProxmoxTask.created_at.desc()).limit(limit).all()
|
||||
deploy_tasks = deploy_q.order_by(DeployTask.created_at.desc()).limit(limit).all()
|
||||
appstore_ops = appstore_q.order_by(AppOperation.started_at.desc()).limit(limit).all()
|
||||
script_executions = script_q.order_by(ScriptExecution.started_at.desc()).limit(limit).all()
|
||||
|
||||
# App Store operations carry only installed_app_id; resolve app name/node/server.
|
||||
ia_ids = {o.installed_app_id for o in appstore_ops if o.installed_app_id}
|
||||
@@ -246,6 +249,7 @@ def get_all_tasks(
|
||||
{t.server_id for t in proxmox_tasks if t.server_id}
|
||||
| {t.server_id for t in deploy_tasks if t.server_id}
|
||||
| {t.get("server_id") for t in appstore_tasks if t.get("server_id")}
|
||||
| {e.server_id for e in script_executions if e.server_id}
|
||||
)
|
||||
server_names = {}
|
||||
if server_ids:
|
||||
@@ -267,7 +271,8 @@ def get_all_tasks(
|
||||
[_enrich(t.to_dict()) for t in bulk_tasks]
|
||||
+ [_enrich(t.to_dict()) for t in proxmox_tasks]
|
||||
+ [_enrich(t.to_dict()) for t in deploy_tasks]
|
||||
+ [_enrich(t) for t in appstore_tasks],
|
||||
+ [_enrich(t) for t in appstore_tasks]
|
||||
+ [_enrich(e.to_task_dict()) for e in script_executions],
|
||||
key=lambda t: t.get("created_at") or "",
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
@@ -301,7 +306,12 @@ def get_active_count(
|
||||
AppOperation.status == "running",
|
||||
).scalar() or 0
|
||||
|
||||
return {"count": bulk_count + proxmox_count + deploy_count + appstore_count}
|
||||
script_count = db.query(func.count(ScriptExecution.id)).filter(
|
||||
ScriptExecution.user_id == current_user.id,
|
||||
ScriptExecution.status == "running",
|
||||
).scalar() or 0
|
||||
|
||||
return {"count": bulk_count + proxmox_count + deploy_count + appstore_count + script_count}
|
||||
|
||||
|
||||
# ─── Proxmox UPID task log from DB ──────────────────────────────────────────
|
||||
@@ -357,6 +367,10 @@ def clear_completed_tasks(
|
||||
AppOperation.user_id == current_user.id,
|
||||
AppOperation.status.in_(done_statuses),
|
||||
).delete(synchronize_session=False)
|
||||
script_deleted = db.query(ScriptExecution).filter(
|
||||
ScriptExecution.user_id == current_user.id,
|
||||
ScriptExecution.status.in_(["success", "failed"]),
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"deleted": bulk_deleted + proxmox_deleted + deploy_deleted + appstore_deleted}
|
||||
return {"deleted": bulk_deleted + proxmox_deleted + deploy_deleted + appstore_deleted + script_deleted}
|
||||
|
||||
|
||||
10
backend/app/api/scripts/__init__.py
Normal file
10
backend/app/api/scripts/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Scripts API package. Экспортирует агрегированный router для main.py."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .catalog import router as catalog_router
|
||||
from .executions import router as executions_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(catalog_router)
|
||||
router.include_router(executions_router)
|
||||
202
backend/app/api/scripts/catalog.py
Normal file
202
backend/app/api/scripts/catalog.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Каталог скриптов — CRUD своих скриптов + управление git-источниками.
|
||||
|
||||
GET /api/scripts — список каталога
|
||||
POST /api/scripts — создать свой скрипт (source=manual)
|
||||
GET /api/scripts/{id} — детали + содержимое
|
||||
PUT /api/scripts/{id} — редактировать (только manual)
|
||||
DELETE /api/scripts/{id} — удалить (только manual)
|
||||
GET /api/scripts/repos — список git-источников
|
||||
POST /api/scripts/repos — добавить git-источник
|
||||
POST /api/scripts/repos/{id}/sync — запустить синк
|
||||
DELETE /api/scripts/repos/{id} — удалить источник (и его скрипты)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...db import get_db
|
||||
from ...models import ScriptCatalog, ScriptGitRepo, User
|
||||
from ...services import script_catalog_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
|
||||
|
||||
|
||||
class ParamField(BaseModel):
|
||||
name: str
|
||||
label: str
|
||||
type: str = "string"
|
||||
required: bool = False
|
||||
default: Optional[str] = None
|
||||
|
||||
|
||||
class ScriptCreateRequest(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
target_type: str = Field(default="guest", pattern="^(node|guest)$")
|
||||
interpreter: str = "/bin/bash"
|
||||
content: str
|
||||
params_schema: List[ParamField] = []
|
||||
|
||||
|
||||
class ScriptUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
target_type: Optional[str] = Field(default=None, pattern="^(node|guest)$")
|
||||
interpreter: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
params_schema: Optional[List[ParamField]] = None
|
||||
|
||||
|
||||
class RepoCreateRequest(BaseModel):
|
||||
url: str
|
||||
branch: str = "main"
|
||||
path_glob: str = "**/*.sh"
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@router.get("/api/scripts")
|
||||
def list_scripts(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:view")),
|
||||
):
|
||||
items = db.query(ScriptCatalog).order_by(ScriptCatalog.name).all()
|
||||
return {"items": [s.to_dict() for s in items]}
|
||||
|
||||
|
||||
@router.post("/api/scripts")
|
||||
def create_script(
|
||||
req: ScriptCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
if not _SLUG_RE.match(req.slug):
|
||||
raise HTTPException(status_code=400, detail="Некорректный slug (a-z0-9-)")
|
||||
if db.query(ScriptCatalog).filter(ScriptCatalog.slug == req.slug).first():
|
||||
raise HTTPException(status_code=409, detail="Скрипт с таким slug уже существует")
|
||||
|
||||
script = ScriptCatalog(
|
||||
name=req.name, slug=req.slug, description=req.description,
|
||||
category=req.category, target_type=req.target_type,
|
||||
interpreter=req.interpreter or "/bin/bash", content=req.content,
|
||||
params_schema=[p.model_dump() for p in req.params_schema],
|
||||
source="manual", created_by=current_user.id,
|
||||
)
|
||||
db.add(script)
|
||||
db.commit()
|
||||
db.refresh(script)
|
||||
return script.to_dict(with_content=True)
|
||||
|
||||
|
||||
@router.get("/api/scripts/repos")
|
||||
def list_repos(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
items = db.query(ScriptGitRepo).order_by(ScriptGitRepo.id).all()
|
||||
return {"items": [r.to_dict() for r in items]}
|
||||
|
||||
|
||||
@router.post("/api/scripts/repos")
|
||||
def create_repo(
|
||||
req: RepoCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
repo = ScriptGitRepo(url=req.url, branch=req.branch, path_glob=req.path_glob, enabled=req.enabled)
|
||||
db.add(repo)
|
||||
db.commit()
|
||||
db.refresh(repo)
|
||||
return repo.to_dict()
|
||||
|
||||
|
||||
@router.post("/api/scripts/repos/{repo_id}/sync")
|
||||
def sync_repo(
|
||||
repo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
repo = db.get(ScriptGitRepo, repo_id)
|
||||
if not repo:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
result = script_catalog_sync.sync_git_repo(db, repo)
|
||||
return {"success": "error" not in result, **result}
|
||||
|
||||
|
||||
@router.delete("/api/scripts/repos/{repo_id}")
|
||||
def delete_repo(
|
||||
repo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
repo = db.get(ScriptGitRepo, repo_id)
|
||||
if not repo:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
db.delete(repo)
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/api/scripts/{script_id}")
|
||||
def get_script(
|
||||
script_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:view")),
|
||||
):
|
||||
script = db.get(ScriptCatalog, script_id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||||
return script.to_dict(with_content=True)
|
||||
|
||||
|
||||
@router.put("/api/scripts/{script_id}")
|
||||
def update_script(
|
||||
script_id: int,
|
||||
req: ScriptUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
script = db.get(ScriptCatalog, script_id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||||
if script.source != "manual":
|
||||
raise HTTPException(status_code=400, detail="Скрипты из git-источника нельзя редактировать вручную")
|
||||
|
||||
for field in ("name", "description", "category", "target_type", "interpreter", "content"):
|
||||
value = getattr(req, field)
|
||||
if value is not None:
|
||||
setattr(script, field, value)
|
||||
if req.params_schema is not None:
|
||||
script.params_schema = [p.model_dump() for p in req.params_schema]
|
||||
|
||||
db.commit()
|
||||
db.refresh(script)
|
||||
return script.to_dict(with_content=True)
|
||||
|
||||
|
||||
@router.delete("/api/scripts/{script_id}")
|
||||
def delete_script(
|
||||
script_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||||
):
|
||||
script = db.get(ScriptCatalog, script_id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||||
if script.source != "manual":
|
||||
raise HTTPException(status_code=400, detail="Скрипты из git-источника удаляются вместе с источником")
|
||||
db.delete(script)
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
125
backend/app/api/scripts/executions.py
Normal file
125
backend/app/api/scripts/executions.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Запуск скриптов и журнал выполнений.
|
||||
|
||||
POST /api/scripts/{id}/execute — запустить скрипт
|
||||
GET /api/scripts/executions — история запусков текущего пользователя (админ — все)
|
||||
GET /api/scripts/executions/{id} — детали запуска (вывод, exit code)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, ScriptCatalog, ScriptExecution, User
|
||||
from ...services import script_engine
|
||||
from ...config import utcnow
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_MAX_OUTPUT = 200_000 # см. SCRIPT_EXECUTION_OUTPUT_LIMIT в models/scripts.py
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
server_id: int
|
||||
node: Optional[str] = None
|
||||
vmid: Optional[int] = None
|
||||
vm_type: Optional[str] = Field(default=None, pattern="^(qemu|lxc)$")
|
||||
params: dict = {}
|
||||
timeout: int = Field(default=60, ge=1, le=600)
|
||||
|
||||
|
||||
def _can_admin(user: User) -> bool:
|
||||
return bool(getattr(user, "is_admin", False)) or user.has_permission("script:manage")
|
||||
|
||||
|
||||
@router.post("/api/scripts/{script_id}/execute")
|
||||
def execute_script(
|
||||
script_id: int,
|
||||
req: ExecuteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:execute")),
|
||||
):
|
||||
script = db.get(ScriptCatalog, script_id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||||
|
||||
server = db.get(ProxmoxServer, req.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Сервер не найден")
|
||||
|
||||
if script.target_type == "guest" and (req.vmid is None or not req.node or not req.vm_type):
|
||||
raise HTTPException(status_code=400, detail="Для guest-скрипта нужны node, vmid и vm_type")
|
||||
|
||||
# Валидация обязательных параметров по схеме скрипта.
|
||||
schema = script.params_schema or []
|
||||
params = dict(req.params or {})
|
||||
for field in schema:
|
||||
name = field.get("name")
|
||||
if not name:
|
||||
continue
|
||||
if name not in params or params[name] in (None, ""):
|
||||
if field.get("required"):
|
||||
raise HTTPException(status_code=400, detail=f"Параметр '{name}' обязателен")
|
||||
if field.get("default") is not None:
|
||||
params[name] = field["default"]
|
||||
params = {k: str(v) for k, v in params.items() if k in {f.get("name") for f in schema}}
|
||||
|
||||
execution = ScriptExecution(
|
||||
script_id=script.id, script_name=script.name,
|
||||
target_type=script.target_type, server_id=server.id,
|
||||
node=req.node, vmid=req.vmid, params_used=params,
|
||||
status="running", user_id=current_user.id,
|
||||
)
|
||||
db.add(execution)
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
|
||||
rendered = script_engine.render_params(script.content, params)
|
||||
result = script_engine.execute(
|
||||
server, target_type=script.target_type, node=req.node, vmid=req.vmid,
|
||||
vm_type=req.vm_type, script=rendered, interpreter=script.interpreter,
|
||||
timeout=req.timeout,
|
||||
)
|
||||
|
||||
execution.status = "success" if result.success else "failed"
|
||||
execution.exit_code = result.exit_code
|
||||
execution.output = (result.output or "")[:_MAX_OUTPUT]
|
||||
execution.error_text = result.error
|
||||
execution.finished_at = utcnow()
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
|
||||
return execution.to_dict()
|
||||
|
||||
|
||||
@router.get("/api/scripts/executions")
|
||||
def list_executions(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:view")),
|
||||
):
|
||||
q = db.query(ScriptExecution)
|
||||
if not _can_admin(current_user):
|
||||
q = q.filter(ScriptExecution.user_id == current_user.id)
|
||||
items = q.order_by(ScriptExecution.started_at.desc()).limit(min(limit, 200)).all()
|
||||
return {"items": [e.to_dict() for e in items]}
|
||||
|
||||
|
||||
@router.get("/api/scripts/executions/{execution_id}")
|
||||
def get_execution(
|
||||
execution_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:view")),
|
||||
):
|
||||
execution = db.get(ScriptExecution, execution_id)
|
||||
if not execution:
|
||||
raise HTTPException(status_code=404, detail="Запуск не найден")
|
||||
if not _can_admin(current_user) and execution.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Нет доступа к этому запуску")
|
||||
return execution.to_dict()
|
||||
@@ -25,6 +25,7 @@ from .api import users as users_router
|
||||
from .api import workspaces as workspaces_router
|
||||
from .api import ssh_keys as ssh_keys_router
|
||||
from .api import appstore as appstore_router
|
||||
from .api import scripts as scripts_router
|
||||
from .logging_middleware import RequestLoggingMiddleware
|
||||
from .language_middleware import LanguageMiddleware
|
||||
from .i18n import I18nService
|
||||
@@ -447,6 +448,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(workspaces_router.router, tags=["workspaces"])
|
||||
app.include_router(ssh_keys_router.router, tags=["ssh-keys"])
|
||||
app.include_router(appstore_router.router, tags=["appstore"])
|
||||
app.include_router(scripts_router.router, tags=["scripts"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer
|
||||
from .misc import Notification, NotificationPreference, BackupJob, AuditLog
|
||||
from .catalog import ImageMirror
|
||||
from .appstore import CatalogApp, InstalledApp, AppOperation
|
||||
from .scripts import ScriptCatalog, ScriptGitRepo, ScriptExecution
|
||||
|
||||
__all__ = [
|
||||
'Base', 'utcnow',
|
||||
@@ -19,5 +20,6 @@ __all__ = [
|
||||
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
|
||||
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog',
|
||||
'ImageMirror',
|
||||
'CatalogApp', 'InstalledApp', 'AppOperation'
|
||||
'CatalogApp', 'InstalledApp', 'AppOperation',
|
||||
'ScriptCatalog', 'ScriptGitRepo', 'ScriptExecution',
|
||||
]
|
||||
|
||||
157
backend/app/models/scripts.py
Normal file
157
backend/app/models/scripts.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from .base import *
|
||||
|
||||
# target_type скрипта: где он выполняется.
|
||||
# "node" — на хосте Proxmox (root@node по SSH)
|
||||
# "guest" — внутри VM (QEMU guest agent) или LXC (pct exec через SSH ноды)
|
||||
SCRIPT_TARGET_TYPES = ("node", "guest")
|
||||
SCRIPT_SOURCES = ("manual", "git")
|
||||
SCRIPT_EXECUTION_STATUSES = ("running", "success", "failed")
|
||||
|
||||
|
||||
class ScriptGitRepo(Base):
|
||||
"""Git-источник каталога скриптов (аналог CatalogProvider для appstore)."""
|
||||
__tablename__ = "script_git_repos"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
url = Column(String(500), nullable=False)
|
||||
branch = Column(String(100), nullable=False, default="main")
|
||||
# glob относительно корня репозитория, напр. "scripts/**/*.sh"
|
||||
path_glob = Column(String(300), nullable=False, default="**/*.sh")
|
||||
enabled = Column(Boolean, nullable=False, default=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_sync_error = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"branch": self.branch,
|
||||
"path_glob": self.path_glob,
|
||||
"enabled": self.enabled,
|
||||
"last_synced_at": self.last_synced_at.isoformat() if self.last_synced_at else None,
|
||||
"last_sync_error": self.last_sync_error,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScriptGitRepo(id={self.id}, url='{self.url}')>"
|
||||
|
||||
|
||||
class ScriptCatalog(Base):
|
||||
"""Скрипт каталога — свой (manual) или синхронизированный из git."""
|
||||
__tablename__ = "script_catalog"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
slug = Column(String(200), nullable=False, unique=True, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True, index=True)
|
||||
|
||||
target_type = Column(String(20), nullable=False, default="guest") # node | guest
|
||||
interpreter = Column(String(100), nullable=False, default="/bin/bash")
|
||||
content = Column(Text, nullable=False)
|
||||
|
||||
# [{name, label, type: string|number|bool, required, default}]
|
||||
params_schema = Column(JSON, nullable=False, default=list)
|
||||
|
||||
source = Column(String(20), nullable=False, default="manual", index=True) # manual | git
|
||||
git_repo_id = Column(Integer, ForeignKey("script_git_repos.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
git_path = Column(String(500), nullable=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
def to_dict(self, *, with_content: bool = False) -> dict:
|
||||
data = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"description": self.description,
|
||||
"category": self.category,
|
||||
"target_type": self.target_type,
|
||||
"interpreter": self.interpreter,
|
||||
"params_schema": self.params_schema or [],
|
||||
"source": self.source,
|
||||
"git_repo_id": self.git_repo_id,
|
||||
"git_path": self.git_path,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if with_content:
|
||||
data["content"] = self.content
|
||||
return data
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScriptCatalog(id={self.id}, slug='{self.slug}', target_type='{self.target_type}')>"
|
||||
|
||||
|
||||
SCRIPT_EXECUTION_OUTPUT_LIMIT = 200_000 # символов, защита от раздувания БД
|
||||
|
||||
|
||||
class ScriptExecution(Base):
|
||||
"""Журнал запуска скрипта (аналог AppOperation)."""
|
||||
__tablename__ = "script_executions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
script_id = Column(Integer, ForeignKey("script_catalog.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
script_name = Column(String(200), nullable=True) # снимок имени на момент запуска
|
||||
|
||||
target_type = Column(String(20), nullable=False) # node | guest
|
||||
server_id = Column(Integer, nullable=True, index=True)
|
||||
node = Column(String(100), nullable=True)
|
||||
vmid = Column(Integer, nullable=True) # None для target_type="node"
|
||||
|
||||
params_used = Column(JSON, nullable=False, default=dict)
|
||||
status = Column(String(20), nullable=False, default="running", index=True)
|
||||
exit_code = Column(Integer, nullable=True)
|
||||
output = Column(Text, nullable=True)
|
||||
error_text = Column(Text, nullable=True)
|
||||
|
||||
user_id = Column(Integer, nullable=False, index=True)
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"kind": "script",
|
||||
"script_id": self.script_id,
|
||||
"script_name": self.script_name,
|
||||
"target_type": self.target_type,
|
||||
"server_id": self.server_id,
|
||||
"node": self.node,
|
||||
"vmid": self.vmid,
|
||||
"params_used": self.params_used or {},
|
||||
"status": self.status,
|
||||
"exit_code": self.exit_code,
|
||||
"output": self.output,
|
||||
"error_text": self.error_text,
|
||||
"user_id": self.user_id,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
||||
}
|
||||
|
||||
def to_task_dict(self) -> dict:
|
||||
"""Представление для общего списка «Задачи» (/api/all-tasks)."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"kind": "script",
|
||||
"type": "script_execution",
|
||||
"description": f"Скрипт: {self.script_name or self.script_id}",
|
||||
"status": self.status,
|
||||
"progress": 100 if self.status != "running" else 50,
|
||||
"step": None,
|
||||
"current_step": None,
|
||||
"error_message": self.error_text,
|
||||
"user_id": self.user_id,
|
||||
"node": self.node,
|
||||
"server_id": self.server_id,
|
||||
"created_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.finished_at.isoformat() if self.finished_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScriptExecution(id={self.id}, script_id={self.script_id}, status='{self.status}')>"
|
||||
@@ -735,6 +735,28 @@ def _create_permissions() -> PermissionRegistry:
|
||||
requires=["app:view"]
|
||||
))
|
||||
|
||||
# Script catalog permissions (bash-скрипты на нодах/гостях)
|
||||
registry.register(Permission(
|
||||
resource="script", action="view",
|
||||
display_name="View Scripts",
|
||||
description="Просмотр каталога скриптов и истории запусков",
|
||||
category="Scripts"
|
||||
))
|
||||
registry.register(Permission(
|
||||
resource="script", action="execute",
|
||||
display_name="Execute Scripts",
|
||||
description="Запуск скриптов из каталога на нодах/ВМ/LXC",
|
||||
category="Scripts",
|
||||
requires=["script:view"]
|
||||
))
|
||||
registry.register(Permission(
|
||||
resource="script", action="manage",
|
||||
display_name="Manage Scripts",
|
||||
description="Создание/редактирование скриптов и git-источников каталога",
|
||||
category="Scripts",
|
||||
requires=["script:view"]
|
||||
))
|
||||
|
||||
# PVE access permissions (authentication realms & API tokens)
|
||||
registry.register(Permission(
|
||||
resource="access", action="view",
|
||||
|
||||
167
backend/app/services/script_catalog_sync.py
Normal file
167
backend/app/services/script_catalog_sync.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Синхронизация каталога скриптов из git-репозитория (аналог appstore_catalog.py
|
||||
sync_catalog(), но проще: bash-файлы с метаданными в заголовочных комментариях
|
||||
вместо отдельных манифестов).
|
||||
|
||||
Конвенция метаданных (первые строки файла, до первой не-комментарной строки
|
||||
после shebang):
|
||||
|
||||
#!/bin/bash
|
||||
# name: Очистка старых логов
|
||||
# description: Удаляет журналы старше 30 дней в /var/log
|
||||
# target: node
|
||||
# category: maintenance
|
||||
# param: DAYS|Дней хранить|number|false|30
|
||||
|
||||
Файлы без `# name:` каталогизируются по имени файла.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob as _glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import utcnow
|
||||
from ..models import ScriptCatalog, ScriptGitRepo
|
||||
|
||||
_META_RE = re.compile(r"^#\s*(name|description|target|category)\s*:\s*(.*)$", re.IGNORECASE)
|
||||
_PARAM_RE = re.compile(r"^#\s*param\s*:\s*(.*)$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_script_metadata(text: str, fallback_name: str) -> dict:
|
||||
name = fallback_name
|
||||
description = None
|
||||
target = "guest"
|
||||
category = None
|
||||
params: List[dict] = []
|
||||
|
||||
for line in text.splitlines()[:60]:
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("#"):
|
||||
if stripped and not stripped.startswith("#!"):
|
||||
break
|
||||
continue
|
||||
m = _META_RE.match(stripped)
|
||||
if m:
|
||||
key, val = m.group(1).lower(), m.group(2).strip()
|
||||
if key == "name":
|
||||
name = val
|
||||
elif key == "description":
|
||||
description = val
|
||||
elif key == "target":
|
||||
target = val if val in ("node", "guest") else "guest"
|
||||
elif key == "category":
|
||||
category = val
|
||||
continue
|
||||
pm = _PARAM_RE.match(stripped)
|
||||
if pm:
|
||||
parts = [p.strip() for p in pm.group(1).split("|")]
|
||||
# name|label|type|required|default
|
||||
p_name = parts[0] if len(parts) > 0 else None
|
||||
if not p_name:
|
||||
continue
|
||||
params.append({
|
||||
"name": p_name,
|
||||
"label": parts[1] if len(parts) > 1 and parts[1] else p_name,
|
||||
"type": parts[2] if len(parts) > 2 and parts[2] else "string",
|
||||
"required": (parts[3].lower() == "true") if len(parts) > 3 else False,
|
||||
"default": parts[4] if len(parts) > 4 else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"target_type": target,
|
||||
"category": category,
|
||||
"params_schema": params,
|
||||
}
|
||||
|
||||
|
||||
def _clone_repo(repo: ScriptGitRepo, dest: str) -> None:
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", "--branch", repo.branch, repo.url, dest],
|
||||
check=True, capture_output=True, timeout=120, text=True,
|
||||
)
|
||||
|
||||
|
||||
def sync_git_repo(db: Session, repo: ScriptGitRepo) -> dict:
|
||||
"""Синхронизировать один git-источник. Идемпотентно (upsert по slug)."""
|
||||
created = updated = skipped = errors = disappeared = 0
|
||||
tmp_dir = tempfile.mkdtemp(prefix="pve_script_repo_")
|
||||
try:
|
||||
try:
|
||||
_clone_repo(repo, tmp_dir)
|
||||
except subprocess.CalledProcessError as e:
|
||||
err = (e.stderr or str(e))[:1000]
|
||||
repo.last_sync_error = err
|
||||
db.commit()
|
||||
return {"error": err, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||||
except subprocess.TimeoutExpired:
|
||||
repo.last_sync_error = "git clone timeout (>120s)"
|
||||
db.commit()
|
||||
return {"error": repo.last_sync_error, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||||
|
||||
pattern = os.path.join(tmp_dir, repo.path_glob)
|
||||
files = _glob.glob(pattern, recursive=True)
|
||||
|
||||
seen_slugs: List[str] = []
|
||||
for path in files:
|
||||
rel_path = os.path.relpath(path, tmp_dir)
|
||||
slug = f"git-{repo.id}-{rel_path}".replace("/", "-").replace("\\", "-")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
logger.warning(f"[script-sync] read {rel_path} failed: {e}")
|
||||
continue
|
||||
|
||||
meta = _parse_script_metadata(content, fallback_name=os.path.basename(rel_path))
|
||||
|
||||
existing = db.query(ScriptCatalog).filter(ScriptCatalog.slug == slug).first()
|
||||
if existing:
|
||||
existing.name = meta["name"]
|
||||
existing.description = meta["description"]
|
||||
existing.target_type = meta["target_type"]
|
||||
existing.category = meta["category"]
|
||||
existing.params_schema = meta["params_schema"]
|
||||
existing.content = content
|
||||
existing.git_path = rel_path
|
||||
updated += 1
|
||||
else:
|
||||
db.add(ScriptCatalog(
|
||||
slug=slug, name=meta["name"], description=meta["description"],
|
||||
target_type=meta["target_type"], category=meta["category"],
|
||||
params_schema=meta["params_schema"], content=content,
|
||||
source="git", git_repo_id=repo.id, git_path=rel_path,
|
||||
))
|
||||
created += 1
|
||||
seen_slugs.append(slug)
|
||||
db.commit()
|
||||
|
||||
# Удалить из этого источника то, что исчезло из репозитория.
|
||||
stale = db.query(ScriptCatalog).filter(
|
||||
ScriptCatalog.git_repo_id == repo.id,
|
||||
~ScriptCatalog.slug.in_(seen_slugs) if seen_slugs else True,
|
||||
).all()
|
||||
for s in stale:
|
||||
db.delete(s)
|
||||
disappeared += 1
|
||||
db.commit()
|
||||
|
||||
repo.last_synced_at = utcnow()
|
||||
repo.last_sync_error = None
|
||||
db.commit()
|
||||
|
||||
return {"created": created, "updated": updated, "skipped": skipped,
|
||||
"errors": errors, "disappeared": disappeared}
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
187
backend/app/services/script_engine.py
Normal file
187
backend/app/services/script_engine.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Движок выполнения скриптов из каталога (backend/app/models/scripts.py).
|
||||
|
||||
Переиспользует существующую инфраструктуру:
|
||||
- SSHClient (root@node) — выполнение на ноде и `pct exec` для LXC (тот же
|
||||
паттерн, что и backend/app/appstore/pipeline.py: base64 -> файл -> exec,
|
||||
без конкатенации пользовательского ввода в shell-команду).
|
||||
- ProxmoxClient.execute_script() — выполнение на VM через QEMU guest agent
|
||||
(backend/app/proxmox/client.py), уже используется в
|
||||
POST /api/{server_id}/vm/{vmid}/execute-script.
|
||||
|
||||
Безопасность параметров: значения параметров НЕ подставляются в тело скрипта
|
||||
текстово. Вместо этого перед скриптом добавляется блок `export VAR='...'` с
|
||||
экранированием одинарных кавычек, скрипт читает их как обычные переменные
|
||||
окружения — тот же принцип, что и .env в appstore pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from ..models import ProxmoxServer
|
||||
from ..proxmox import ProxmoxClient
|
||||
from ..ssh_client import SSHClient
|
||||
|
||||
_CMD_TIMEOUT = 60
|
||||
|
||||
|
||||
class ScriptExecError(RuntimeError):
|
||||
"""Ошибка выполнения скрипта с человекочитаемым сообщением."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecResult:
|
||||
success: bool
|
||||
output: str
|
||||
exit_code: int
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _shell_quote(value: str) -> str:
|
||||
"""Обернуть значение в одинарные кавычки, безопасно для shell."""
|
||||
return "'" + str(value).replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def render_params(content: str, params: Dict[str, str]) -> str:
|
||||
"""Добавить блок export-переменных перед телом скрипта.
|
||||
|
||||
Скрипт обращается к параметрам как к обычным переменным окружения
|
||||
($NAME), значения никогда не конкатенируются в исполняемые команды.
|
||||
"""
|
||||
if not params:
|
||||
return content
|
||||
lines = ["#!/bin/sh", "# --- параметры, подставленные каталогом скриптов ---"]
|
||||
for name, value in params.items():
|
||||
# Имя переменной ограничено тем, что уже провалидировано схемой
|
||||
# (см. api/scripts.py) — здесь только защитное экранирование значения.
|
||||
lines.append(f"export {name}={_shell_quote('' if value is None else value)}")
|
||||
lines.append("# --- конец параметров ---")
|
||||
return "\n".join(lines) + "\n" + content
|
||||
|
||||
|
||||
def _node_ssh(server: ProxmoxServer, client: ProxmoxClient) -> SSHClient:
|
||||
ssh_host = (server.ip_address or server.hostname or "").split(":")[0]
|
||||
return SSHClient(
|
||||
hostname=ssh_host,
|
||||
username="root",
|
||||
password=getattr(client, "_password", None),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) -> str:
|
||||
output, exit_code = ssh.execute(cmd, return_exit_code=True, timeout=timeout)
|
||||
if exit_code == -1:
|
||||
raise ScriptExecError(f"{what}: таймаут (>{timeout}с) или обрыв SSH")
|
||||
if exit_code != 0:
|
||||
raise ScriptExecError(f"{what}: exit={exit_code}: {(output or '').strip()[:2000]}")
|
||||
return output or ""
|
||||
|
||||
|
||||
def run_on_node(server: ProxmoxServer, client: ProxmoxClient, script: str,
|
||||
interpreter: str, timeout: int) -> ExecResult:
|
||||
"""Выполнить скрипт на хосте Proxmox (root@node)."""
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Не удалось подключиться по SSH к {server.name}")
|
||||
|
||||
b64 = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
tmp = f"/tmp/pve_script_{abs(hash(script)) % 100000}_{int(time.time())}"
|
||||
try:
|
||||
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp} && chmod +x {tmp}",
|
||||
what="write script on node")
|
||||
output, exit_code = ssh.execute(f"{interpreter} {tmp}", return_exit_code=True, timeout=timeout)
|
||||
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
|
||||
except ScriptExecError as e:
|
||||
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
|
||||
finally:
|
||||
try:
|
||||
ssh.execute(f"rm -f {tmp}", timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_on_lxc(server: ProxmoxServer, client: ProxmoxClient, node: str, vmid: int,
|
||||
script: str, interpreter: str, timeout: int) -> ExecResult:
|
||||
"""Выполнить скрипт внутри LXC через `pct exec` (SSH ноды, нет guest agent для LXC)."""
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Не удалось подключиться по SSH к {server.name}")
|
||||
|
||||
b64 = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
tmp_node = f"/tmp/pve_script_{vmid}_{int(time.time())}"
|
||||
tmp_ct = f"/tmp/pve_script_{vmid}.sh"
|
||||
try:
|
||||
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp_node}",
|
||||
what="write script on node")
|
||||
_run(ssh, f"pct push {vmid} {tmp_node} {tmp_ct}", what="pct push script")
|
||||
output, exit_code = ssh.execute(
|
||||
f"pct exec {vmid} -- {interpreter} {tmp_ct}",
|
||||
return_exit_code=True, timeout=timeout,
|
||||
)
|
||||
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
|
||||
except ScriptExecError as e:
|
||||
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
|
||||
finally:
|
||||
try:
|
||||
ssh.execute(f"rm -f {tmp_node}", timeout=10)
|
||||
ssh.execute(f"pct exec {vmid} -- rm -f {tmp_ct}", timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_on_qemu(client: ProxmoxClient, node: str, vmid: int, script: str,
|
||||
interpreter: str, timeout: int) -> ExecResult:
|
||||
"""Выполнить скрипт внутри VM через QEMU guest agent."""
|
||||
result = client.execute_script(node, vmid, script, interpreter=interpreter, timeout=timeout)
|
||||
if not result.get("success"):
|
||||
return ExecResult(
|
||||
success=False, output=result.get("stdout", "") or "",
|
||||
exit_code=result.get("exit_code", -1),
|
||||
error=result.get("error") or result.get("stderr"),
|
||||
)
|
||||
return ExecResult(
|
||||
success=result.get("exit_code", 0) == 0,
|
||||
output=result.get("stdout", "") or "",
|
||||
exit_code=result.get("exit_code", 0),
|
||||
error=result.get("stderr") or None,
|
||||
)
|
||||
|
||||
|
||||
def execute(server: ProxmoxServer, *, target_type: str, node: Optional[str],
|
||||
vmid: Optional[int], vm_type: Optional[str], script: str,
|
||||
interpreter: str, timeout: int) -> ExecResult:
|
||||
"""Точка входа: выполнить скрипт на нужной цели.
|
||||
|
||||
target_type: "node" (хост Proxmox) | "guest" (VM/LXC).
|
||||
vm_type (только для guest): "qemu" | "lxc".
|
||||
"""
|
||||
try:
|
||||
client = ProxmoxClient.from_server(server)
|
||||
except Exception as e:
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Не удалось создать Proxmox-клиент: {e}")
|
||||
|
||||
try:
|
||||
if target_type == "node":
|
||||
return run_on_node(server, client, script, interpreter, timeout)
|
||||
if target_type == "guest":
|
||||
if vmid is None or not node:
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error="Для guest-скрипта нужны node и vmid")
|
||||
if vm_type == "lxc":
|
||||
return run_on_lxc(server, client, node, vmid, script, interpreter, timeout)
|
||||
return run_on_qemu(client, node, vmid, script, interpreter, timeout)
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Неизвестный target_type: {target_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"[script_engine] execute failed: {e}")
|
||||
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
|
||||
62
frontend/src/features/scripts/ExecutionDetailDialog.tsx
Normal file
62
frontend/src/features/scripts/ExecutionDetailDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useScriptExecution } from '@/hooks/use-scripts';
|
||||
|
||||
interface Props {
|
||||
executionId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ExecutionDetailDialog({ executionId, onOpenChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const open = !!executionId;
|
||||
const { data: execution, isLoading } = useScriptExecution(executionId || 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onOpenChange(false)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('scripts.execution_title', 'Запуск: {{name}}', { name: execution?.script_name || execution?.script_id })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading || !execution ? (
|
||||
<div className="py-10 text-center"><Loader2 className="mx-auto h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{execution.status === 'success' && <CheckCircle2 className="h-4 w-4 text-green-600" />}
|
||||
{execution.status === 'failed' && <XCircle className="h-4 w-4 text-destructive" />}
|
||||
{execution.status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-primary" />}
|
||||
<span>{t('scripts.exit_code', 'Exit code')}: {execution.exit_code ?? '—'}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{execution.target_type === 'node' ? execution.node : `${execution.node} / vmid ${execution.vmid}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{Object.keys(execution.params_used || {}).length > 0 && (
|
||||
<div className="rounded-md border p-2 text-xs">
|
||||
{Object.entries(execution.params_used).map(([k, v]) => (
|
||||
<div key={k}><span className="text-muted-foreground">{k}</span> = {v}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{execution.error_text && (
|
||||
<p className="text-sm text-destructive">{execution.error_text}</p>
|
||||
)}
|
||||
|
||||
<pre className="max-h-96 overflow-auto rounded bg-muted/40 p-2 text-xs whitespace-pre-wrap">
|
||||
{execution.output || t('scripts.no_output', '(нет вывода)')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.close', 'Закрыть')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
188
frontend/src/features/scripts/ScriptEditDialog.tsx
Normal file
188
frontend/src/features/scripts/ScriptEditDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { useCreateScript, useUpdateScript } from '@/hooks/use-scripts';
|
||||
import type { ScriptCatalogItem, ScriptParamField } from '@/types/scripts';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
script: ScriptCatalogItem | null;
|
||||
}
|
||||
|
||||
const emptyParam = (): ScriptParamField => ({ name: '', label: '', type: 'string', required: false, default: '' });
|
||||
|
||||
export default function ScriptEditDialog({ open, onOpenChange, script }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!script;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [targetType, setTargetType] = useState<'node' | 'guest'>('guest');
|
||||
const [interpreter, setInterpreter] = useState('/bin/bash');
|
||||
const [content, setContent] = useState('#!/bin/bash\n');
|
||||
const [params, setParams] = useState<ScriptParamField[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (script) {
|
||||
setName(script.name);
|
||||
setSlug(script.slug);
|
||||
setDescription(script.description || '');
|
||||
setCategory(script.category || '');
|
||||
setTargetType(script.target_type);
|
||||
setInterpreter(script.interpreter);
|
||||
setContent(script.content || '');
|
||||
setParams(script.params_schema || []);
|
||||
} else {
|
||||
setName(''); setSlug(''); setDescription(''); setCategory('');
|
||||
setTargetType('guest'); setInterpreter('/bin/bash'); setContent('#!/bin/bash\n'); setParams([]);
|
||||
}
|
||||
}, [open, script]);
|
||||
|
||||
const create = useCreateScript();
|
||||
const update = useUpdateScript(script?.id || 0);
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
const handleNameChange = (v: string) => {
|
||||
setName(v);
|
||||
if (!isEdit) {
|
||||
setSlug(v.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !slug.trim() || !content.trim()) {
|
||||
toast.error(t('scripts.validation_required', 'Заполните имя, slug и содержимое скрипта'));
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name, slug, description: description || undefined, category: category || undefined,
|
||||
target_type: targetType, interpreter, content,
|
||||
params_schema: params.filter((p) => p.name.trim()),
|
||||
};
|
||||
const onDone = {
|
||||
onSuccess: () => { toast.success(t('scripts.saved', 'Скрипт сохранён')); onOpenChange(false); },
|
||||
onError: (e: unknown) => toast.error((e as Error).message),
|
||||
};
|
||||
if (isEdit) update.mutate(payload, onDone);
|
||||
else create.mutate(payload, onDone);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? t('scripts.edit_title', 'Редактировать скрипт') : t('scripts.new_title', 'Новый скрипт')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.name', 'Имя')}</Label>
|
||||
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="Очистка логов" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.slug', 'Slug')}</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} disabled={isEdit} placeholder="clean-logs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.description', 'Описание')}</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.category', 'Категория')}</Label>
|
||||
<Input value={category} onChange={(e) => setCategory(e.target.value)} placeholder="maintenance" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.target_type', 'Цель выполнения')}</Label>
|
||||
<Select value={targetType} onValueChange={(v) => setTargetType((v as 'node' | 'guest') ?? 'guest')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="guest">{t('scripts.target_guest', 'VM/LXC')}</SelectItem>
|
||||
<SelectItem value="node">{t('scripts.target_node', 'Нода Proxmox')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.interpreter', 'Интерпретатор')}</Label>
|
||||
<Input value={interpreter} onChange={(e) => setInterpreter(e.target.value)} placeholder="/bin/bash" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.content', 'Содержимое скрипта')}</Label>
|
||||
<textarea
|
||||
className="min-h-[220px] w-full rounded-lg border border-input bg-transparent p-2.5 font-mono text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
{t('scripts.params_hint', 'Параметры доступны как переменные окружения ($NAME).')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('scripts.params', 'Параметры')}</Label>
|
||||
<Button size="sm" variant="outline" onClick={() => setParams([...params, emptyParam()])}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />{t('scripts.add_param', 'Добавить параметр')}
|
||||
</Button>
|
||||
</div>
|
||||
{params.map((p, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_100px_80px_auto] items-center gap-2">
|
||||
<Input
|
||||
placeholder="NAME" value={p.name}
|
||||
onChange={(e) => setParams(params.map((x, j) => j === i ? { ...x, name: e.target.value.toUpperCase() } : x))}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('scripts.param_label', 'Подпись')} value={p.label}
|
||||
onChange={(e) => setParams(params.map((x, j) => j === i ? { ...x, label: e.target.value } : x))}
|
||||
/>
|
||||
<Select value={p.type} onValueChange={(v) => setParams(params.map((x, j) => j === i ? { ...x, type: (v as ScriptParamField['type']) ?? 'string' } : x))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="bool">bool</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center justify-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox" checked={p.required}
|
||||
onChange={(e) => setParams(params.map((x, j) => j === i ? { ...x, required: e.target.checked } : x))}
|
||||
/>
|
||||
{t('scripts.required', 'обяз.')}
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={() => setParams(params.filter((_, j) => j !== i))}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={handleSave} disabled={pending}>
|
||||
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save', 'Сохранить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
177
frontend/src/features/scripts/ScriptExecuteDialog.tsx
Normal file
177
frontend/src/features/scripts/ScriptExecuteDialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Play, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import { useExecuteScript } from '@/hooks/use-scripts';
|
||||
import type { ScriptCatalogItem } from '@/types/scripts';
|
||||
import type { ScriptExecution } from '@/types/scripts';
|
||||
|
||||
interface Props {
|
||||
script: ScriptCatalogItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ScriptExecuteDialog({ script, onOpenChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const open = !!script;
|
||||
|
||||
const { data: servers = [] } = useServers();
|
||||
const { data: vms = [] } = useVirtualMachines();
|
||||
|
||||
const [serverId, setServerId] = useState('');
|
||||
const [node, setNode] = useState('');
|
||||
const [guestKey, setGuestKey] = useState(''); // `${server_id}:${vmid}:${type}`
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<ScriptExecution | null>(null);
|
||||
|
||||
const serverIdNum = serverId ? Number(serverId) : 0;
|
||||
const { data: nodesData } = useNodes(serverIdNum || 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
|
||||
const guestOptions = useMemo(
|
||||
() => vms.filter((v) => !v.template && (!serverIdNum || v.server_id === serverIdNum)),
|
||||
[vms, serverIdNum],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setServerId(''); setNode(''); setGuestKey(''); setResult(null);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const p of script?.params_schema || []) {
|
||||
defaults[p.name] = p.default != null ? String(p.default) : '';
|
||||
}
|
||||
setParamValues(defaults);
|
||||
}, [open, script]);
|
||||
|
||||
const execute = useExecuteScript(script?.id || 0);
|
||||
|
||||
const selectedGuest = useMemo(() => {
|
||||
if (!guestKey) return null;
|
||||
const [sid, vmid] = guestKey.split(':');
|
||||
return guestOptions.find((v) => String(v.server_id) === sid && String(v.vmid) === vmid) || null;
|
||||
}, [guestKey, guestOptions]);
|
||||
|
||||
const canRun = useMemo(() => {
|
||||
if (!script || !serverIdNum) return false;
|
||||
if (script.target_type === 'node') return !!node;
|
||||
return !!selectedGuest;
|
||||
}, [script, serverIdNum, node, selectedGuest]);
|
||||
|
||||
const handleRun = () => {
|
||||
if (!script) return;
|
||||
for (const p of script.params_schema) {
|
||||
if (p.required && !paramValues[p.name]?.trim()) {
|
||||
toast.error(t('scripts.param_required', 'Параметр "{{label}}" обязателен', { label: p.label }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
execute.mutate(
|
||||
{
|
||||
server_id: serverIdNum,
|
||||
node: script.target_type === 'node' ? node : selectedGuest?.node,
|
||||
vmid: script.target_type === 'guest' ? selectedGuest?.vmid : undefined,
|
||||
vm_type: script.target_type === 'guest' ? selectedGuest?.type : undefined,
|
||||
params: paramValues,
|
||||
},
|
||||
{
|
||||
onSuccess: (r) => setResult(r),
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!script) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onOpenChange(false)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('scripts.run_title', 'Запустить: {{name}}', { name: script.name })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.server', 'Сервер Proxmox')}</Label>
|
||||
<Select value={serverId} onValueChange={(v) => { setServerId(v ?? ''); setNode(''); setGuestKey(''); }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('scripts.pick_server', 'Выберите сервер')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{script.target_type === 'node' ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.node', 'Нода')}</Label>
|
||||
<Select value={node} onValueChange={(v) => setNode(v ?? '')} disabled={!serverIdNum}>
|
||||
<SelectTrigger><SelectValue placeholder={t('scripts.pick_node', 'Выберите ноду')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('scripts.guest', 'VM / LXC')}</Label>
|
||||
<Select value={guestKey} onValueChange={(v) => setGuestKey(v ?? '')} disabled={!serverIdNum}>
|
||||
<SelectTrigger><SelectValue placeholder={t('scripts.pick_guest', 'Выберите ВМ/контейнер')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{guestOptions.map((v) => (
|
||||
<SelectItem key={`${v.server_id}:${v.vmid}`} value={`${v.server_id}:${v.vmid}:${v.type}`}>
|
||||
{v.vmid} — {v.name} ({v.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{script.params_schema.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
{script.params_schema.map((p) => (
|
||||
<div key={p.name} className="space-y-1.5">
|
||||
<Label>{p.label}{p.required && ' *'}</Label>
|
||||
<Input
|
||||
value={paramValues[p.name] ?? ''}
|
||||
onChange={(e) => setParamValues({ ...paramValues, [p.name]: e.target.value })}
|
||||
type={p.type === 'number' ? 'number' : 'text'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-1.5 rounded-md border bg-muted/40 p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{result.status === 'success'
|
||||
? <CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
: <XCircle className="h-4 w-4 text-destructive" />}
|
||||
<span>{t('scripts.exit_code', 'Exit code')}: {result.exit_code ?? '—'}</span>
|
||||
</div>
|
||||
{result.error_text && <p className="text-xs text-destructive">{result.error_text}</p>}
|
||||
{result.output && (
|
||||
<pre className="max-h-64 overflow-auto rounded bg-background p-2 text-xs whitespace-pre-wrap">{result.output}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.close', 'Закрыть')}</Button>
|
||||
<Button onClick={handleRun} disabled={!canRun || execute.isPending}>
|
||||
{execute.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
||||
{t('scripts.run', 'Запустить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
245
frontend/src/features/scripts/ScriptsPage.tsx
Normal file
245
frontend/src/features/scripts/ScriptsPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Terminal, Plus, RefreshCw, Loader2, Play, Pencil, Trash2, GitBranch } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
useScripts, useDeleteScript,
|
||||
useScriptRepos, useCreateScriptRepo, useSyncScriptRepo, useDeleteScriptRepo,
|
||||
useScriptExecutions,
|
||||
} from '@/hooks/use-scripts';
|
||||
import type { ScriptCatalogItem } from '@/types/scripts';
|
||||
import ScriptEditDialog from './ScriptEditDialog';
|
||||
import ScriptExecuteDialog from './ScriptExecuteDialog';
|
||||
import ExecutionDetailDialog from './ExecutionDetailDialog';
|
||||
|
||||
export default function ScriptsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState('catalog');
|
||||
|
||||
const { data: scripts = [], isLoading } = useScripts();
|
||||
const deleteScript = useDeleteScript();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ScriptCatalogItem | null>(null);
|
||||
const [execScript, setExecScript] = useState<ScriptCatalogItem | null>(null);
|
||||
|
||||
const { data: repos = [] } = useScriptRepos();
|
||||
const createRepo = useCreateScriptRepo();
|
||||
const syncRepo = useSyncScriptRepo();
|
||||
const deleteRepo = useDeleteScriptRepo();
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
|
||||
const { data: executions = [] } = useScriptExecutions();
|
||||
const [execDetailId, setExecDetailId] = useState<number | null>(null);
|
||||
|
||||
const manualScripts = useMemo(() => scripts.filter((s) => s.source === 'manual'), [scripts]);
|
||||
const gitScripts = useMemo(() => scripts.filter((s) => s.source === 'git'), [scripts]);
|
||||
|
||||
const handleDelete = (script: ScriptCatalogItem) => {
|
||||
if (!confirm(t('scripts.confirm_delete', 'Удалить скрипт "{{name}}"?', { name: script.name }))) return;
|
||||
deleteScript.mutate(script.id, {
|
||||
onSuccess: () => toast.success(t('scripts.deleted', 'Скрипт удалён')),
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddRepo = () => {
|
||||
if (!repoUrl.trim()) return;
|
||||
createRepo.mutate({ url: repoUrl.trim() }, {
|
||||
onSuccess: () => { setRepoUrl(''); toast.success(t('scripts.repo_added', 'Источник добавлен')); },
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
});
|
||||
};
|
||||
|
||||
const renderScriptCard = (script: ScriptCatalogItem) => (
|
||||
<Card key={script.id} className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{script.name}</p>
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">{script.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-2xs">
|
||||
{script.target_type === 'node' ? t('scripts.target_node', 'Нода') : t('scripts.target_guest', 'VM/LXC')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{script.category && <Badge variant="secondary" className="text-2xs">{script.category}</Badge>}
|
||||
{script.source === 'git' && (
|
||||
<Badge variant="outline" className="gap-1 text-2xs"><GitBranch className="h-3 w-3" />{script.git_path}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 pt-1">
|
||||
<Button size="sm" onClick={() => setExecScript(script)}>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />{t('scripts.run', 'Запустить')}
|
||||
</Button>
|
||||
{script.source === 'manual' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditing(script); setEditOpen(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDelete(script)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t('scripts.title', 'Каталог скриптов')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('scripts.subtitle', 'Bash-скрипты для выполнения на нодах Proxmox и гостях (VM/LXC)')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setEditing(null); setEditOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />{t('scripts.new_script', 'Новый скрипт')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="catalog">{t('scripts.tab_catalog', 'Каталог')}</TabsTrigger>
|
||||
<TabsTrigger value="repos">{t('scripts.tab_repos', 'Git-источники')}</TabsTrigger>
|
||||
<TabsTrigger value="executions">{t('scripts.tab_executions', 'История запусков')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="catalog" className="mt-4 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="py-20 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка...')}</div>
|
||||
) : scripts.length === 0 ? (
|
||||
<div className="py-20 text-center text-muted-foreground">
|
||||
<Terminal className="mx-auto mb-3 h-12 w-12 opacity-40" />
|
||||
<p>{t('scripts.no_scripts', 'Пока нет ни одного скрипта')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{manualScripts.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{manualScripts.map(renderScriptCard)}
|
||||
</div>
|
||||
)}
|
||||
{gitScripts.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{gitScripts.map(renderScriptCard)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="repos" className="mt-4 space-y-4">
|
||||
<Card className="flex flex-wrap items-center gap-2 p-3">
|
||||
<input
|
||||
className="h-8 min-w-[280px] flex-1 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
placeholder={t('scripts.repo_url_placeholder', 'https://github.com/user/scripts.git')}
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddRepo} disabled={createRepo.isPending}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />{t('scripts.add_repo', 'Добавить источник')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{repos.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t('scripts.no_repos', 'Git-источники не настроены')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<Card key={repo.id} className="flex flex-wrap items-center justify-between gap-2 p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{repo.url}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{repo.branch} · {repo.path_glob}
|
||||
{repo.last_synced_at && ` · ${t('scripts.last_synced', 'синк')}: ${new Date(repo.last_synced_at).toLocaleString()}`}
|
||||
</p>
|
||||
{repo.last_sync_error && (
|
||||
<p className="text-xs text-destructive">{repo.last_sync_error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
onClick={() => syncRepo.mutate(repo.id, {
|
||||
onSuccess: (r) => r.success
|
||||
? toast.success(t('scripts.sync_done', 'Синхронизировано: +{{created}} / изм. {{updated}}', { created: r.created, updated: r.updated }))
|
||||
: toast.error(r.error || t('scripts.sync_failed', 'Ошибка синхронизации')),
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
})}
|
||||
disabled={syncRepo.isPending}
|
||||
>
|
||||
{syncRepo.isPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1.5 h-3.5 w-3.5" />}
|
||||
{t('scripts.sync', 'Синхронизировать')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => deleteRepo.mutate(repo.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="executions" className="mt-4">
|
||||
{executions.length === 0 ? (
|
||||
<div className="py-20 text-center text-sm text-muted-foreground">
|
||||
{t('scripts.no_executions', 'Пока нет запусков')}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('scripts.col_script', 'Скрипт')}</TableHead>
|
||||
<TableHead>{t('scripts.col_target', 'Цель')}</TableHead>
|
||||
<TableHead>{t('scripts.col_status', 'Статус')}</TableHead>
|
||||
<TableHead>{t('scripts.col_exit_code', 'Exit code')}</TableHead>
|
||||
<TableHead>{t('scripts.col_started', 'Запущен')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map((e) => (
|
||||
<TableRow key={e.id} className="cursor-pointer" onClick={() => setExecDetailId(e.id)}>
|
||||
<TableCell>{e.script_name || e.script_id}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{e.target_type === 'node' ? e.node : `${e.node} / vmid ${e.vmid}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.status === 'success' ? 'secondary' : e.status === 'failed' ? 'destructive' : 'outline'}>
|
||||
{e.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{e.exit_code ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{e.started_at ? new Date(e.started_at).toLocaleString() : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ScriptEditDialog open={editOpen} onOpenChange={setEditOpen} script={editing} />
|
||||
<ScriptExecuteDialog script={execScript} onOpenChange={(open) => !open && setExecScript(null)} />
|
||||
<ExecutionDetailDialog
|
||||
executionId={execDetailId}
|
||||
onOpenChange={(open) => !open && setExecDetailId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/src/hooks/use-scripts.ts
Normal file
149
frontend/src/hooks/use-scripts.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { ScriptCatalogItem, ScriptExecution, ScriptGitRepoItem, ScriptParamField } from '@/types/scripts';
|
||||
|
||||
export const scriptKeys = {
|
||||
catalog: ['scripts-catalog'] as const,
|
||||
detail: (id: number) => ['scripts-catalog', id] as const,
|
||||
repos: ['scripts-repos'] as const,
|
||||
executions: ['scripts-executions'] as const,
|
||||
execution: (id: number) => ['scripts-executions', id] as const,
|
||||
};
|
||||
|
||||
// ── Каталог ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useScripts() {
|
||||
return useQuery({
|
||||
queryKey: scriptKeys.catalog,
|
||||
queryFn: () => apiClient.get<{ items: ScriptCatalogItem[] }>('/api/scripts').then((r) => r.items),
|
||||
});
|
||||
}
|
||||
|
||||
export function useScript(id: number) {
|
||||
return useQuery({
|
||||
queryKey: scriptKeys.detail(id),
|
||||
queryFn: () => apiClient.get<ScriptCatalogItem>(`/api/scripts/${id}`),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ScriptCreateArgs {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
target_type: 'node' | 'guest';
|
||||
interpreter?: string;
|
||||
content: string;
|
||||
params_schema: ScriptParamField[];
|
||||
}
|
||||
|
||||
export function useCreateScript() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ScriptCreateArgs) => apiClient.post<ScriptCatalogItem>('/api/scripts', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: scriptKeys.catalog }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateScript(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<ScriptCreateArgs>) => apiClient.put<ScriptCatalogItem>(`/api/scripts/${id}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: scriptKeys.catalog });
|
||||
qc.invalidateQueries({ queryKey: scriptKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteScript() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/api/scripts/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: scriptKeys.catalog }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Git-источники ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useScriptRepos() {
|
||||
return useQuery({
|
||||
queryKey: scriptKeys.repos,
|
||||
queryFn: () => apiClient.get<{ items: ScriptGitRepoItem[] }>('/api/scripts/repos').then((r) => r.items),
|
||||
});
|
||||
}
|
||||
|
||||
export interface RepoCreateArgs {
|
||||
url: string;
|
||||
branch?: string;
|
||||
path_glob?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useCreateScriptRepo() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: RepoCreateArgs) => apiClient.post<ScriptGitRepoItem>('/api/scripts/repos', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: scriptKeys.repos }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSyncScriptRepo() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiClient.post<{ success: boolean; created: number; updated: number; disappeared: number; error?: string }>(
|
||||
`/api/scripts/repos/${id}/sync`,
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: scriptKeys.repos });
|
||||
qc.invalidateQueries({ queryKey: scriptKeys.catalog });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteScriptRepo() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/api/scripts/repos/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: scriptKeys.repos });
|
||||
qc.invalidateQueries({ queryKey: scriptKeys.catalog });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Выполнение ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExecuteScriptArgs {
|
||||
server_id: number;
|
||||
node?: string;
|
||||
vmid?: number;
|
||||
vm_type?: 'qemu' | 'lxc';
|
||||
params: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export function useExecuteScript(scriptId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ExecuteScriptArgs) => apiClient.post<ScriptExecution>(`/api/scripts/${scriptId}/execute`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: scriptKeys.executions }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useScriptExecutions(limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: scriptKeys.executions,
|
||||
queryFn: () => apiClient.get<{ items: ScriptExecution[] }>(`/api/scripts/executions?limit=${limit}`).then((r) => r.items),
|
||||
});
|
||||
}
|
||||
|
||||
export function useScriptExecution(id: number) {
|
||||
return useQuery({
|
||||
queryKey: scriptKeys.execution(id),
|
||||
queryFn: () => apiClient.get<ScriptExecution>(`/api/scripts/executions/${id}`),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Check,
|
||||
Store,
|
||||
Package,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
@@ -81,6 +82,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ label: 'App Store', icon: Store, path: '/appstore', permission: 'app:view' },
|
||||
{ label: 'My Apps', icon: Package, path: '/my-apps', permission: 'app:view' },
|
||||
{ label: 'Scripts', icon: Terminal, path: '/scripts', permission: 'script:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@ const SettingsPage = lazy(() => import('./features/settings/SettingsPage.tsx'));
|
||||
const AppStorePage = lazy(() => import('./features/appstore/AppStorePage.tsx'));
|
||||
const AppDetailPage = lazy(() => import('./features/appstore/AppDetailPage.tsx'));
|
||||
const MyAppsPage = lazy(() => import('./features/appstore/MyAppsPage.tsx'));
|
||||
const ScriptsPage = lazy(() => import('./features/scripts/ScriptsPage.tsx'));
|
||||
|
||||
function LazyFallback() {
|
||||
return <div className="flex items-center justify-center py-20 text-muted-foreground text-sm">Loading…</div>;
|
||||
@@ -92,6 +93,7 @@ export const router = createBrowserRouter([
|
||||
{ path: '/appstore', element: <SuspenseWrap><AppStorePage /></SuspenseWrap> },
|
||||
{ path: '/appstore/:appId', element: <SuspenseWrap><AppDetailPage /></SuspenseWrap> },
|
||||
{ path: '/my-apps', element: <SuspenseWrap><MyAppsPage /></SuspenseWrap> },
|
||||
{ path: '/scripts', element: <SuspenseWrap><ScriptsPage /></SuspenseWrap> },
|
||||
{ path: '/images', element: <SuspenseWrap><ImagesPage /></SuspenseWrap> },
|
||||
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
|
||||
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
|
||||
|
||||
60
frontend/src/types/scripts.ts
Normal file
60
frontend/src/types/scripts.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Типы модуля «Каталог скриптов» (bash-скрипты на нодах/ВМ/LXC).
|
||||
|
||||
export type ScriptTargetType = 'node' | 'guest';
|
||||
export type ScriptSource = 'manual' | 'git';
|
||||
export type ScriptExecutionStatus = 'running' | 'success' | 'failed';
|
||||
|
||||
export interface ScriptParamField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'string' | 'number' | 'bool';
|
||||
required: boolean;
|
||||
default?: string | null;
|
||||
}
|
||||
|
||||
export interface ScriptCatalogItem {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
target_type: ScriptTargetType;
|
||||
interpreter: string;
|
||||
params_schema: ScriptParamField[];
|
||||
source: ScriptSource;
|
||||
git_repo_id: number | null;
|
||||
git_path: string | null;
|
||||
created_by: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ScriptGitRepoItem {
|
||||
id: number;
|
||||
url: string;
|
||||
branch: string;
|
||||
path_glob: string;
|
||||
enabled: boolean;
|
||||
last_synced_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
}
|
||||
|
||||
export interface ScriptExecution {
|
||||
id: number;
|
||||
kind: 'script';
|
||||
script_id: number | null;
|
||||
script_name: string | null;
|
||||
target_type: ScriptTargetType;
|
||||
server_id: number | null;
|
||||
node: string | null;
|
||||
vmid: number | null;
|
||||
params_used: Record<string, string>;
|
||||
status: ScriptExecutionStatus;
|
||||
exit_code: number | null;
|
||||
output: string | null;
|
||||
error_text: string | null;
|
||||
user_id: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user