2026-10-29 14:39:39 +03:00 | | | '''Веб-приложение менеджера терминалов.'''
|
2026-10-31 06:48:02 +03:00 | | | __version__ = '0.5.1'
|
2025-10-14 16:04:37 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | import tempfile
|
2025-10-21 16:08:01 +03:00 | | | from contextlib import asynccontextmanager
|
2026-10-29 14:39:39 +03:00 | | | from pathlib import Path as FilePath
|
| | |
|
| | | from fastapi import FastAPI, HTTPException, Request
|
2025-10-07 21:48:20 +03:00 | | | from fastapi.responses import HTMLResponse, JSONResponse
|
2026-10-29 14:39:39 +03:00 | | | from fastapi.exceptions import RequestValidationError
|
| | | from fastapi.staticfiles import StaticFiles
|
2025-10-21 16:08:01 +03:00 | | | from fastapi.templating import Jinja2Templates
|
2026-10-29 14:39:39 +03:00 | | | from pydantic import BaseModel, Field, ValidationError
|
| | | from starlette.concurrency import run_in_threadpool
|
| | |
|
| | | from config import ConfigStore
|
| | | from terminal_manager import PROJECT_DIR, TerminalManager
|
| | |
|
| | |
|
| | | class AccountInput(BaseModel):
|
| | | name: str = ''
|
| | | login: int | str = ''
|
| | | server: str = ''
|
| | | password: str = ''
|
| | | clear_account: bool = False
|
| | |
|
2025-10-21 16:08:01 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | class CreateInput(AccountInput):
|
| | | folder: str = ''
|
| | |
|
| | |
|
| | | class SettingsInput(BaseModel):
|
| | | mt5_folder: str = Field(min_length=1)
|
| | | terminal_template: str = Field(min_length=1)
|
2026-10-31 06:48:02 +03:00 | | | mt5_installation: str = ''
|
2026-10-29 14:39:39 +03:00 | | | service_ex5: str = ''
|
| | | startup_timeout_sec: int = Field(default=180, ge=10, le=900)
|
| | | disk_warning_mb: int = Field(default=1024, ge=0)
|
2025-10-21 16:08:01 +03:00 | | |
|
| | |
|
| | | @asynccontextmanager
|
| | | async def lifespan(app: FastAPI):
|
2026-10-29 14:39:39 +03:00 | | | manager = TerminalManager(ConfigStore())
|
| | | app.state.manager = manager
|
| | | try:
|
| | | yield
|
| | | finally:
|
| | | await run_in_threadpool(manager.close)
|
2025-10-04 15:10:53 +03:00 | | |
|
2025-10-14 14:59:40 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | app = FastAPI(lifespan=lifespan)
|
| | | app.mount('/static', StaticFiles(directory=PROJECT_DIR / 'static'), name='static')
|
| | | templates = Jinja2Templates(directory=PROJECT_DIR / 'templates')
|
2025-10-10 10:16:59 +03:00 | | |
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | @app.exception_handler(RequestValidationError)
|
| | | async def invalid_request(request: Request, error: RequestValidationError):
|
| | | # Стандартный ответ Pydantic может содержать исходный объект запроса,
|
| | | # включая пароль. Здесь возвращаем только нейтральное описание ошибки.
|
| | | return JSONResponse(status_code=422, content={'detail': 'Проверьте поля запроса и типы значений.'})
|
2025-10-07 21:48:20 +03:00 | | |
|
2025-10-14 14:59:40 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | def submit(request, action, folder, values=None):
|
| | | try:
|
| | | return request.app.state.manager.submit(action, folder, values)
|
| | | except ValueError as error:
|
| | | raise HTTPException(400, str(error)) from None
|
2025-10-21 16:08:01 +03:00 | | |
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | @app.get('/', response_class=HTMLResponse)
|
| | | def index(request: Request):
|
| | | return templates.TemplateResponse(request, 'index.html',
|
| | | {'instances': request.app.state.manager.snapshot()})
|
2025-10-07 21:48:20 +03:00 | | |
|
2025-10-14 14:59:40 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | @app.get('/settings', response_class=HTMLResponse)
|
| | | def settings_page(request: Request):
|
| | | return templates.TemplateResponse(request, 'settings.html')
|
2025-10-16 20:41:52 +03:00 | | |
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | @app.post('/instances/{name}')
|
| | | def info_instance(request: Request, name: str):
|
| | | return request.app.state.manager.info(name)
|
| | |
|
| | |
|
| | | @app.post('/start/{name}', status_code=202)
|
| | | def start_instance(request: Request, name: str):
|
| | | return submit(request, 'start', name)
|
| | |
|
| | |
|
| | | @app.post('/stop/{name}', status_code=202)
|
| | | def stop_instance(request: Request, name: str):
|
| | | return submit(request, 'stop', name)
|
| | |
|
| | |
|
| | | @app.post('/create/{name}', status_code=202)
|
| | | def create_instance(request: Request, name: str, values: AccountInput | None = None):
|
| | | return submit(request, 'create', name, (values or AccountInput()).model_dump())
|
| | |
|
| | |
|
| | | @app.post('/api/terminals', status_code=202)
|
| | | def create_terminal(request: Request, values: CreateInput):
|
| | | folder = values.folder or request.app.state.manager.suggest_name()
|
| | | return submit(request, 'create', folder, values.model_dump(exclude={'folder'}))
|
| | |
|
| | |
|
| | | @app.put('/api/terminals/{name}/account', status_code=202)
|
| | | def save_account(request: Request, name: str, values: AccountInput):
|
| | | return submit(request, 'account', name, values.model_dump())
|
| | |
|
| | |
|
| | | @app.get('/api/jobs/{job_id}')
|
| | | def job_status(request: Request, job_id: str):
|
| | | try:
|
| | | return request.app.state.manager.job(job_id)
|
| | | except KeyError:
|
| | | raise HTTPException(404, 'Операция не найдена.') from None
|
| | |
|
| | |
|
| | | @app.get('/api/settings')
|
| | | def get_settings(request: Request):
|
| | | return request.app.state.manager.settings()
|
| | |
|
| | |
|
| | | @app.get('/api/project')
|
| | | def project_status(request: Request):
|
| | | manager = request.app.state.manager
|
| | | return manager.updates.status(manager.store.data)
|
| | |
|
| | |
|
| | | @app.post('/api/project/{action}', status_code=202)
|
| | | def project_operation(request: Request, action: str):
|
| | | actions = {'check': 'check_updates', 'update': 'update_project', 'deploy': 'deploy_service'}
|
| | | if action not in actions:
|
| | | raise HTTPException(404, 'Неизвестная операция проекта.')
|
| | | return submit(request, actions[action], '')
|
| | |
|
| | |
|
| | | @app.put('/api/settings')
|
| | | def save_settings(request: Request, values: SettingsInput):
|
| | | try:
|
| | | request.app.state.manager.save_settings(values.model_dump())
|
| | | except ValidationError:
|
| | | raise HTTPException(400, 'Проверьте параметры конфигурации.') from None
|
| | | except (ValueError, OSError) as error:
|
| | | raise HTTPException(400, str(error)) from None
|
| | | return {'saved': True}
|
| | |
|
| | |
|
| | | @app.put('/api/files/{kind}')
|
| | | async def upload_file(request: Request, kind: str):
|
| | | manager = request.app.state.manager
|
| | | if kind not in ('terminal', 'editor', 'servers', 'service'):
|
| | | raise HTTPException(404, 'Неизвестный тип файла.')
|
| | | limit = (16 if kind == 'servers' else 256) * 1024 * 1024
|
| | | size = 0
|
| | | temporary = None
|
| | | try:
|
| | | # Потоковая загрузка не требует держать EXE целиком в памяти.
|
| | | with tempfile.NamedTemporaryFile(delete=False) as out:
|
| | | temporary = FilePath(out.name)
|
| | | async for chunk in request.stream():
|
| | | size += len(chunk)
|
| | | if size > limit:
|
| | | raise HTTPException(413, 'Превышен допустимый размер файла.')
|
| | | out.write(chunk)
|
| | | if not size:
|
| | | raise HTTPException(400, 'Получен пустой файл.')
|
| | | await run_in_threadpool(manager.install_upload, kind, temporary)
|
| | | except (ValueError, OSError) as error:
|
| | | raise HTTPException(400, str(error)) from None
|
| | | finally:
|
| | | if temporary:
|
| | | temporary.unlink(missing_ok=True)
|
| | | return {'uploaded': True, 'size': size}
|