2 Commits

Author SHA1 Message Date
markmorado
8ca33681ca feat(scripts): каталог bash-скриптов для нод и гостей (VM/LXC)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 59s
Deploy to Production / Trigger production update (push) Successful in 8s
Добавлена библиотека скриптов по образцу 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>
2026-07-11 04:35:43 +05:00
markmorado
f2ee09ab5a polish(ui): пульсация статус-точки для online, моно-цифры в бейджах, fade-up анимация
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 04:35:29 +05:00
24 changed files with 1834 additions and 34 deletions

View File

@@ -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}

View 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)

View 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}

View 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()

View File

@@ -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

View File

@@ -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',
]

View 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}')>"

View File

@@ -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",

View 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)

View 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))

View File

@@ -27,7 +27,7 @@ export function StatusDot({ status, className, pulse }: StatusDotProps) {
title={status}
className={cn('relative inline-block h-2 w-2 rounded-full', color, className)}
>
{pulse && status === 'running' && (
{pulse && (status === 'running' || status === 'online') && (
<span className={cn('absolute inset-0 animate-ping rounded-full opacity-75', color)} />
)}
</span>

View File

@@ -497,7 +497,7 @@ export default function ClusterPage() {
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-primary" />
<h2 className="font-semibold">{name}</h2>
<Badge variant="secondary">{members.length} {t('cluster.nodes', 'nodes')}</Badge>
<Badge variant="secondary" className="font-mono">{members.length} {t('cluster.nodes', 'nodes')}</Badge>
</div>
{members.length === 2 && (
@@ -521,7 +521,7 @@ export default function ClusterPage() {
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot status={m.is_online ? 'online' : 'offline'} />
<StatusDot status={m.is_online ? 'online' : 'offline'} pulse />
{m.is_online ? t('common.online', 'Online') : t('common.offline', 'Offline')}
</span>
<Button
@@ -548,7 +548,7 @@ export default function ClusterPage() {
<div className="flex items-center gap-2">
<Boxes className="h-5 w-5 text-muted-foreground" />
<h2 className="font-semibold">{t('cluster.standalone', 'Standalone servers')}</h2>
<Badge variant="outline">{standalone.length}</Badge>
<Badge variant="outline" className="font-mono">{standalone.length}</Badge>
</div>
{standalone.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('cluster.no_standalone', 'No standalone servers.')}</p>
@@ -566,7 +566,7 @@ export default function ClusterPage() {
</div>
</div>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot status={s.is_online ? 'online' : 'offline'} />
<StatusDot status={s.is_online ? 'online' : 'offline'} pulse />
{s.is_online ? t('common.online', 'Online') : t('common.offline', 'Offline')}
</span>
</div>

View File

@@ -31,7 +31,7 @@ function ResourceBar({ label, percent }: { label: string; percent: number }) {
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums font-medium">{percent.toFixed(1)}%</span>
<span className="font-mono tabular-nums font-medium">{percent.toFixed(1)}%</span>
</div>
<div className="h-1.5 rounded-full bg-track overflow-hidden">
<div
@@ -43,6 +43,12 @@ function ResourceBar({ label, percent }: { label: string; percent: number }) {
)
}
// Worst-case health across a set of percentages — drives status accent bars.
function healthColor(...percents: number[]) {
const worst = Math.max(...percents)
return worst >= 90 ? 'bg-danger' : worst >= 70 ? 'bg-warning' : 'bg-success'
}
// ==================== SVG Circular Gauge ====================
function CircularGauge({ label, value }: { label: string; value: number }) {
const r = 26
@@ -64,10 +70,10 @@ function CircularGauge({ label, value }: { label: string; value: number }) {
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[13px] font-bold tabular-nums">{Math.round(clamped)}</span>
<span className="font-mono text-[13px] font-bold tabular-nums">{Math.round(clamped)}</span>
</div>
</div>
<span className="text-2xs text-muted-foreground tracking-wide">{label}</span>
<span className="text-2xs font-mono text-muted-foreground tracking-wide uppercase">{label}</span>
</div>
)
}
@@ -90,8 +96,9 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
const cols = availableIPs > 0 ? 'grid-cols-3' : 'grid-cols-2'
return (
<Card className="h-full">
<CardContent className="p-5 flex flex-col gap-4 h-full">
<Card className="h-full gap-0 py-0 overflow-hidden">
<div className={`h-[3px] w-full ${healthColor(memPercent, diskPercent)}`} />
<CardContent className="p-5 pt-4 flex flex-col gap-4 h-full">
<div className="flex items-center gap-1.5">
<button className="flex items-center gap-1.5 group text-left rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card">
<span className="text-base font-semibold group-hover:text-primary transition-colors">{clusterName}</span>
@@ -103,16 +110,16 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
{availableIPs > 0 && (
<div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">IPv4 доступно</p>
<p className="text-3xl font-bold tabular-nums text-success">{availableIPs.toLocaleString()}</p>
<p className="font-mono text-3xl font-bold tabular-nums text-success">{availableIPs.toLocaleString()}</p>
</div>
)}
<div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Узлов</p>
<p className="text-3xl font-bold tabular-nums">{nodeStats.length}</p>
<p className="font-mono text-3xl font-bold tabular-nums">{nodeStats.length}</p>
</div>
<div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">VM</p>
<p className="text-3xl font-bold tabular-nums">{totalVMs + totalCTs}</p>
<p className="font-mono text-3xl font-bold tabular-nums">{totalVMs + totalCTs}</p>
</div>
</div>
@@ -123,7 +130,7 @@ function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPe
<div className="flex items-center justify-between text-xs pt-1">
<span className="text-muted-foreground">
Оверселлинг RAM = <span className="text-foreground font-medium">{overselling}</span>
Оверселлинг RAM = <span className="font-mono text-foreground font-medium">{overselling}</span>
</span>
<span className="flex items-center gap-1 text-muted-foreground">
<ExternalLink className="h-3 w-3" /> Grafana
@@ -175,12 +182,12 @@ function TasksTodayCard() {
{rows.map(({ label, value, red }) => (
<div key={label} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${red ? 'text-danger' : ''}`}>{value}</span>
<span className={`font-mono text-sm font-semibold tabular-nums ${red ? 'text-danger' : ''}`}>{value}</span>
</div>
))}
<div className="border-t border-border pt-2.5 flex items-center justify-between">
<span className="text-sm font-medium">Всего</span>
<span className="text-sm font-bold tabular-nums">{today.length}</span>
<span className="font-mono text-sm font-bold tabular-nums">{today.length}</span>
</div>
</CardContent>
</Card>
@@ -192,8 +199,9 @@ function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
cpuPercent: number; memPercent: number; serverName: string; serverCount: number
}) {
return (
<Card className="h-full">
<CardHeader className="pb-2 pt-5 px-5">
<Card className="h-full gap-0 py-0 overflow-hidden">
<div className={`h-[3px] w-full ${healthColor(cpuPercent, memPercent)}`} />
<CardHeader className="pb-2 pt-4 px-5">
<CardTitle className="text-sm font-semibold truncate">{serverName || 'Сервер'}</CardTitle>
</CardHeader>
<CardContent className="px-5 pb-5 space-y-4">
@@ -204,7 +212,7 @@ function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Серверов:</span>
<span className="font-medium">{serverCount}</span>
<span className="font-mono font-medium">{serverCount}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Платформа:</span>
@@ -293,13 +301,13 @@ function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) {
</span>
)}
</div>
<span className={`text-right tabular-nums text-xs font-medium ${cpu >= 90 ? 'text-danger' : cpu >= 70 ? 'text-warning' : ''}`}>
<span className={`font-mono text-right tabular-nums text-xs font-medium ${cpu >= 90 ? 'text-danger' : cpu >= 70 ? 'text-warning' : ''}`}>
{cpu.toFixed(0)}
</span>
<span className={`text-right tabular-nums text-xs ${ram >= 90 ? 'text-danger' : ram >= 70 ? 'text-warning' : ''}`}>
<span className={`font-mono text-right tabular-nums text-xs ${ram >= 90 ? 'text-danger' : ram >= 70 ? 'text-warning' : ''}`}>
{ram.toFixed(0)}
</span>
<span className="text-right tabular-nums text-xs text-muted-foreground">{disk.toFixed(0)}</span>
<span className="font-mono text-right tabular-nums text-xs text-muted-foreground">{disk.toFixed(0)}</span>
</div>
)
})
@@ -469,7 +477,7 @@ export default function DashboardPage() {
return (
<div className="space-y-4">
{/* Top row: cluster overview | tasks | node stats */}
<div className="grid gap-4 lg:grid-cols-[1fr_220px_210px]">
<div className="grid gap-4 lg:grid-cols-[1fr_220px_210px] animate-fade-up">
<ClusterOverviewCard
serverList={serverList}
nodeStats={nodeStats}
@@ -488,7 +496,9 @@ export default function DashboardPage() {
</div>
{/* Bottom row: nodes table + disk chart */}
<ClusterNodesSection nodeStats={nodeStats} />
<div className="animate-fade-up" style={{ animationDelay: '80ms' }}>
<ClusterNodesSection nodeStats={nodeStats} />
</div>
</div>
)
}

View File

@@ -100,8 +100,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
const { data: networks = [] } = useIPAMNetworks();
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id, undefined, 'rootdir');
const { data: vmStorages = [] } = useLXCStorages(selectedServer?.id, undefined, 'images');
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id, selectedNode || undefined, 'rootdir');
const { data: vmStorages = [] } = useLXCStorages(selectedServer?.id, selectedNode || undefined, 'images');
const { data: nodesData } = useNodes(selectedServer?.id ?? 0);
const nodes = nodesData?.nodes ?? [];
const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId);

View File

@@ -14,6 +14,7 @@ import type { ProxmoxServer, ProxmoxServerCreate } from '@/types';
import { formatUptime } from '@/lib/format';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
import { StatusDot } from '@/components/shared/status-dot';
import ServerUpdatesBadge from './ServerUpdatesBadge';
/**
@@ -370,14 +371,15 @@ export default function NodesPage() {
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Monitor className="h-3.5 w-3.5" />
<span>{srvVMs.length} VM</span>
<span className="font-mono tabular-nums">{srvVMs.length} VM</span>
</div>
<div className="flex items-center gap-1.5">
<Container className="h-3.5 w-3.5" />
<span>{srvCTs.length} LXC</span>
<span className="font-mono tabular-nums">{srvCTs.length} LXC</span>
</div>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
<Badge variant={srv.is_online ? 'default' : 'destructive'} className="gap-1.5">
<StatusDot status={srv.is_online ? 'online' : 'offline'} pulse className="h-1.5 w-1.5" />
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
});
}

View File

@@ -163,6 +163,21 @@
@apply bg-background text-foreground font-sans antialiased;
}
/* Section entrance on first paint — signals load hierarchy, not decoration. */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Respect users who ask for less motion: neutralise transitions/animations
globally instead of auditing every component (WCAG reduced-motion). */
@media (prefers-reduced-motion: reduce) {

View File

@@ -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' },
],
},
{

View File

@@ -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> },

View 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;
}