| '''Веб-приложение менеджера терминалов.'''
|
| __version__ = '0.5.1'
|
|
|
| import tempfile
|
| from contextlib import asynccontextmanager
|
| from pathlib import Path as FilePath
|
|
|
| from fastapi import FastAPI, HTTPException, Request
|
| from fastapi.responses import HTMLResponse, JSONResponse
|
| from fastapi.exceptions import RequestValidationError
|
| from fastapi.staticfiles import StaticFiles
|
| from fastapi.templating import Jinja2Templates
|
| 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
|
|
|
|
|
| class CreateInput(AccountInput):
|
| folder: str = ''
|
|
|
|
|
| class SettingsInput(BaseModel):
|
| mt5_folder: str = Field(min_length=1)
|
| terminal_template: str = Field(min_length=1)
|
| mt5_installation: str = ''
|
| service_ex5: str = ''
|
| startup_timeout_sec: int = Field(default=180, ge=10, le=900)
|
| disk_warning_mb: int = Field(default=1024, ge=0)
|
|
|
|
|
| @asynccontextmanager
|
| async def lifespan(app: FastAPI):
|
| manager = TerminalManager(ConfigStore())
|
| app.state.manager = manager
|
| try:
|
| yield
|
| finally:
|
| await run_in_threadpool(manager.close)
|
|
|
|
|
| app = FastAPI(lifespan=lifespan)
|
| app.mount('/static', StaticFiles(directory=PROJECT_DIR / 'static'), name='static')
|
| templates = Jinja2Templates(directory=PROJECT_DIR / 'templates')
|
|
|
|
|
| @app.exception_handler(RequestValidationError)
|
| async def invalid_request(request: Request, error: RequestValidationError):
|
| # Стандартный ответ Pydantic может содержать исходный объект запроса,
|
| # включая пароль. Здесь возвращаем только нейтральное описание ошибки.
|
| return JSONResponse(status_code=422, content={'detail': 'Проверьте поля запроса и типы значений.'})
|
|
|
|
|
| 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
|
|
|
|
|
| @app.get('/', response_class=HTMLResponse)
|
| def index(request: Request):
|
| return templates.TemplateResponse(request, 'index.html',
|
| {'instances': request.app.state.manager.snapshot()})
|
|
|
|
|
| @app.get('/settings', response_class=HTMLResponse)
|
| def settings_page(request: Request):
|
| return templates.TemplateResponse(request, 'settings.html')
|
|
|
|
|
| @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}
|