From c384087f3870e5b559cc4ab112caeadd629c1827 Mon Sep 17 00:00:00 2001 From: inmake Date: Fri, 5 Dec 2025 18:04:01 +0500 Subject: [PATCH] Update package dependencies and enhance session management features - Added react-hot-toast for improved user notifications in the application. - Updated baseline-browser-mapping to version 2.9.2. - Introduced maxInstances property in Session and App schemas to manage concurrent instances. - Refactored server session handling to enforce instance limits and improve error messaging. - Removed GPU memory management from server registration and session assignment logic for cleaner implementation. - Enhanced error handling in session management with localized messages for better user experience. --- client/package-lock.json | 34 ++++- client/package.json | 1 + client/src/lib/errorUtils.ts | 63 ++++++++ client/src/pages/TestPage.tsx | 17 ++- client/src/types/Session.ts | 1 + server/pm2.config.cjs | 3 +- server/src/controllers/server.ts | 37 +---- server/src/controllers/session.ts | 43 +++--- server/src/db/schema/apps.ts | 2 +- server/src/db/schema/servers.ts | 2 +- server/src/services/server/index.ts | 32 +---- server/src/services/serverSession/index.ts | 159 ++++++++++++--------- session-server/src/index.ts | 94 +----------- 13 files changed, 234 insertions(+), 254 deletions(-) create mode 100644 client/src/lib/errorUtils.ts diff --git a/client/package-lock.json b/client/package-lock.json index 79d02fd..619f420 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,6 +16,7 @@ "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-qr-code": "^2.0.18", "react-router": "^7.9.3", "socket.io-client": "^4.8.1", @@ -30,6 +31,7 @@ "@types/uuid": "^11.0.0", "@vitejs/plugin-react-swc": "^4.1.0", "autoprefixer": "^10.4.21", + "baseline-browser-mapping": "^2.9.2", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", @@ -1905,11 +1907,10 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.13.tgz", - "integrity": "sha512-7s16KR8io8nIBWQyCYhmFhd+ebIzb9VKTzki+wOJXHTxTnV6+mFGH3+Jwn1zoKaY9/H9T/0BcKCZnzXljPnpSQ==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.2.tgz", + "integrity": "sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==", "dev": true, - "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2168,7 +2169,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2810,6 +2810,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3687,6 +3695,22 @@ "react": "^19.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/client/package.json b/client/package.json index 5690bca..8dc4ba6 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "@types/uuid": "^11.0.0", "@vitejs/plugin-react-swc": "^4.1.0", "autoprefixer": "^10.4.21", + "baseline-browser-mapping": "^2.9.2", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", diff --git a/client/src/lib/errorUtils.ts b/client/src/lib/errorUtils.ts new file mode 100644 index 0000000..0aed27c --- /dev/null +++ b/client/src/lib/errorUtils.ts @@ -0,0 +1,63 @@ +import { HTTPError } from "ky"; + +/** + * Извлекает сообщение об ошибке из ответа API + * Elysia возвращает ошибки как строку в теле ответа или как JSON с полем message + */ +export async function extractErrorMessage(error: unknown): Promise { + // Если это HTTPError от ky + if (error instanceof HTTPError) { + try { + // Сначала пытаемся получить текст ответа + const responseText = await error.response.text(); + + // Пытаемся распарсить как JSON + try { + const errorData = JSON.parse(responseText); + // Проверяем различные форматы ответа об ошибке + return ( + errorData.message || + errorData.error || + responseText || + error.response.statusText || + `Ошибка ${error.response.status}` + ); + } catch { + // Если не JSON, значит это строка - используем её напрямую + // Elysia возвращает ошибки как строку при использовании status(code, message) + return responseText || error.response.statusText || `Ошибка ${error.response.status}`; + } + } catch { + // Если не удалось получить текст ответа, используем статус код + const statusMessages: Record = { + 400: "Неверный запрос", + 401: "Требуется авторизация", + 403: "Доступ запрещен", + 404: "Ресурс не найден", + 409: "Конфликт данных", + 500: "Внутренняя ошибка сервера", + 503: "Сервис временно недоступен", + }; + + return ( + statusMessages[error.response.status] || + error.response.statusText || + `Ошибка ${error.response.status}` + ); + } + } + + // Если это обычная Error + if (error instanceof Error) { + return error.message; + } + + // Если это объект с полем message + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + + // Дефолтное сообщение + return "Произошла неизвестная ошибка"; +} + diff --git a/client/src/pages/TestPage.tsx b/client/src/pages/TestPage.tsx index 6267842..747bfa4 100644 --- a/client/src/pages/TestPage.tsx +++ b/client/src/pages/TestPage.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { api } from "../lib/api"; import { useNavigate } from "react-router"; +import toast from "react-hot-toast"; +import { extractErrorMessage } from "../lib/errorUtils"; interface Session { id: string; @@ -44,9 +46,18 @@ function TestPage() { navigate(`/sessions/${response.session.id}`); } catch (err) { console.error("Failed to start session:", err); - setError( - err instanceof Error ? err.message : "Не удалось запустить приложение" - ); + + // Извлекаем сообщение об ошибке из ответа + const errorMessage = await extractErrorMessage(err); + + // Показываем ошибку через toast + toast.error(errorMessage, { + duration: 5000, + position: "top-center", + }); + + // Также сохраняем для отображения в UI (если нужно) + setError(errorMessage); } finally { setIsLoading(false); } diff --git a/client/src/types/Session.ts b/client/src/types/Session.ts index 11f44a4..2905b35 100644 --- a/client/src/types/Session.ts +++ b/client/src/types/Session.ts @@ -22,6 +22,7 @@ export interface Session { title: string; gpuLimitMb: number | null; psVersion: number | null; + maxInstances: number | null; }; server?: { id: string; diff --git a/server/pm2.config.cjs b/server/pm2.config.cjs index a3b4028..2a8bd65 100644 --- a/server/pm2.config.cjs +++ b/server/pm2.config.cjs @@ -2,7 +2,8 @@ module.exports = { apps: [ { name: "stream.graff.estate-server", - script: "bun ./dist", + interpreter: "bun", + script: "./dist/index.js", }, ], }; diff --git a/server/src/controllers/server.ts b/server/src/controllers/server.ts index 7d5dd3f..cc6f287 100644 --- a/server/src/controllers/server.ts +++ b/server/src/controllers/server.ts @@ -44,7 +44,6 @@ export const serverController = new Elysia({ prefix: "/servers" }) // Если сервер существует, обновить его информацию const updatedServer = await serverService.update(existingServer.id, { localIp, - gpuFreeMb, branchId, location, tier: finalTier, @@ -58,7 +57,6 @@ export const serverController = new Elysia({ prefix: "/servers" }) localIp, hostname, type, - gpuFreeMb, branchId, location, tier: finalTier, @@ -71,37 +69,12 @@ export const serverController = new Elysia({ prefix: "/servers" }) localIp: t.String({ minLength: 7, maxLength: 45 }), hostname: t.String({ minLength: 1, maxLength: 255 }), type: t.Union([t.Literal("stream"), t.Literal("local")]), - gpuFreeMb: t.Number({ minimum: 0 }), branchId: t.Optional(t.String({ format: "uuid" })), location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), }), } ) - // PATCH /servers/:id/gpu - обновить свободную память GPU (публичный endpoint) - .patch( - "/:id/gpu", - async ({ params, body, status }) => { - const { id } = params; - const { gpuFreeMb } = body as { gpuFreeMb: number }; - - // Проверить существование сервера - const server = await serverService.findById(id); - - if (!server) { - return status(404, "Server not found"); - } - - const updatedServer = await serverService.updateGpuMemory(id, gpuFreeMb); - - return { server: updatedServer }; - }, - { - body: t.Object({ - gpuFreeMb: t.Number({ minimum: 0 }), - }), - } - ) // GET /servers/:id/sessions - получить сессии для конкретного сервера (публичный endpoint) .get("/:id/sessions", async ({ params, query, status }) => { const { id } = params; @@ -180,12 +153,11 @@ export const serverController = new Elysia({ prefix: "/servers" }) .post( "/", async ({ body, set }) => { - const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } = + const { localIp, hostname, type, branchId, location, tier } = body as { localIp: string; hostname: string; type: "stream" | "local"; - gpuFreeMb: number; branchId?: string; location?: "ru1" | "uae1"; tier?: "demo" | "prod"; @@ -214,7 +186,6 @@ export const serverController = new Elysia({ prefix: "/servers" }) localIp, hostname, type, - gpuFreeMb, branchId, location, tier: finalTier, @@ -227,7 +198,6 @@ export const serverController = new Elysia({ prefix: "/servers" }) localIp: t.String({ minLength: 7, maxLength: 45 }), hostname: t.String({ minLength: 1, maxLength: 255 }), type: t.Union([t.Literal("stream"), t.Literal("local")]), - gpuFreeMb: t.Number({ minimum: 0 }), branchId: t.Optional(t.String({ format: "uuid" })), location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), @@ -239,11 +209,10 @@ export const serverController = new Elysia({ prefix: "/servers" }) "/:id", async ({ params, body, status, set }) => { const { id } = params; - const { localIp, hostname, gpuFreeMb, branchId, location, tier } = + const { localIp, hostname, branchId, location, tier } = body as { localIp?: string; hostname?: string; - gpuFreeMb?: number; branchId?: string; location?: "ru1" | "uae1"; tier?: "demo" | "prod"; @@ -275,7 +244,6 @@ export const serverController = new Elysia({ prefix: "/servers" }) const updatedServer = await serverService.update(id, { localIp, hostname, - gpuFreeMb, branchId, location, tier, @@ -287,7 +255,6 @@ export const serverController = new Elysia({ prefix: "/servers" }) body: t.Object({ localIp: t.Optional(t.String({ minLength: 7, maxLength: 45 })), hostname: t.Optional(t.String({ minLength: 1, maxLength: 255 })), - gpuFreeMb: t.Optional(t.Number({ minimum: 0 })), branchId: t.Optional(t.String({ format: "uuid" })), location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), diff --git a/server/src/controllers/session.ts b/server/src/controllers/session.ts index 1aef4d7..e238b3c 100644 --- a/server/src/controllers/session.ts +++ b/server/src/controllers/session.ts @@ -33,7 +33,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) const session = await serverSessionService.findById(id); if (!session) { - return status(404, "Session not found"); + return status(404, "Сессия не найдена"); } // Обновить сессию @@ -69,27 +69,18 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) // POST /sessions/:id/assign-server - назначить сервер для сессии (публичный endpoint для сессионного сервера) .post( "/:id/assign-server", - async ({ params, body, status }) => { + async ({ params, status }) => { const { id } = params; - const { requiredGpuMb } = body as { requiredGpuMb?: number }; try { - const updatedSession = await serverSessionService.assignServer( - id, - requiredGpuMb - ); + const updatedSession = await serverSessionService.assignServer(id); return { session: updatedSession }; } catch (error) { if (error instanceof Error) { return status(400, error.message); } - return status(500, "Failed to assign server"); + return status(500, "Не удалось назначить сервер"); } - }, - { - body: t.Object({ - requiredGpuMb: t.Optional(t.Number({ minimum: 0 })), - }), } ) // Endpoints с optional auth (доступны для неавторизованных пользователей) @@ -111,26 +102,26 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) }); if (!app) { - return status(404, "App not found"); + return status(404, "Приложение не найдено"); } // Если пользователь не авторизован if (!currentUser) { // Проверяем наличие guestId для неавторизованных пользователей if (!guestId) { - return status(400, "Guest ID is required for unauthorized users"); + return status(400, "Для неавторизованных пользователей требуется Guest ID"); } // Проверяем, что режим - stream (только stream поддерживает demo) if (mode !== "stream") { - return status(401, "Authorization required for local sessions"); + return status(401, "Для local сессий требуется авторизация"); } // Неавторизованные пользователи могут использовать только demo-серверы if (tier && tier !== "demo") { return status( 403, - "Unauthorized users can only use demo tier servers" + "Неавторизованные пользователи могут использовать только demo серверы" ); } @@ -142,7 +133,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) if (demoServers.length === 0) { return status( 503, - "No available demo servers. Please login to use production servers." + "Нет доступных demo серверов. Пожалуйста, войдите в систему для использования production серверов." ); } @@ -161,7 +152,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) if (error instanceof Error) { return status(503, error.message); } - return status(500, "Failed to create session"); + return status(500, "Не удалось создать сессию"); } } @@ -173,7 +164,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) ); if (hasActive) { - return status(409, "User already has an active session for this app"); + return status(409, "У вас уже есть активная сессия для этого приложения"); } // Для режима stream - проверяем наличие серверов нужного tier @@ -182,7 +173,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) tier ); if (availableServers.length === 0) { - return status(503, `No available ${tier} servers`); + return status(503, `Нет доступных ${tier} серверов`); } } @@ -201,7 +192,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) if (error instanceof Error) { return status(503, error.message); } - return status(500, "Failed to create session"); + return status(500, "Не удалось создать сессию"); } }, { @@ -272,7 +263,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) ); if (!session) { - return status(404, "Session not found"); + return status(404, "Сессия не найдена"); } // Обновить сессию @@ -344,12 +335,12 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) ); if (!session) { - return status(404, "Session not found"); + return status(404, "Сессия не найдена"); } // Проверить, что сессия активна if (session.status !== "started") { - return status(400, "Can only extend active sessions"); + return status(400, "Можно продлевать только активные сессии"); } // Продлить сессию @@ -360,7 +351,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" }) if (error instanceof Error) { return status(400, error.message); } - return status(500, "Failed to extend session"); + return status(500, "Не удалось продлить сессию"); } }, { diff --git a/server/src/db/schema/apps.ts b/server/src/db/schema/apps.ts index de67984..e750686 100644 --- a/server/src/db/schema/apps.ts +++ b/server/src/db/schema/apps.ts @@ -13,8 +13,8 @@ export const apps = pgTable("apps", { id: uuid("id").primaryKey().defaultRandom(), name: varchar("name").notNull(), // Имя приложения (например, "minecraft") title: varchar("title").notNull(), // Название приложения (например, "Майнкрафт") - gpuLimitMb: integer("gpu_limit_mb"), // Лимит GPU в мегабайтах (только для stream серверов) psVersion: integer("ps_version"), // Версия Pixel Streaming (например, "1") + maxInstances: integer("max_instances").default(1).notNull(), // Лимит на количество одновременно запущенных экземпляров приложения createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), diff --git a/server/src/db/schema/servers.ts b/server/src/db/schema/servers.ts index 52c322f..f01dd8f 100644 --- a/server/src/db/schema/servers.ts +++ b/server/src/db/schema/servers.ts @@ -19,10 +19,10 @@ export const servers = pgTable("servers", { localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars hostname: varchar("hostname").notNull(), // hostname сервера type: serverTypeEnum("type").notNull(), // stream, local - gpuFreeMb: integer("gpu_free_mb").notNull(), // свободная память на GPU в мегабайтах branchId: uuid("branch_id").references(() => branches.id), // филиал, на котором находится сервер (nullable для локальных серверов) location: serverLocationEnum("location"), // ru1, uae1 (только для stream) tier: serverTierEnum("tier"), // demo, prod (только для stream) + maxApps: integer("max_apps").default(1).notNull(), // Максимальное количество запущенных приложений на сервере createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), diff --git a/server/src/services/server/index.ts b/server/src/services/server/index.ts index 5c81f74..37c251a 100644 --- a/server/src/services/server/index.ts +++ b/server/src/services/server/index.ts @@ -10,19 +10,19 @@ export interface CreateServerParams { localIp: string; hostname: string; type: ServerType; - gpuFreeMb: number; branchId?: string; location?: ServerLocation; tier?: ServerTier; + maxApps?: number; } export interface UpdateServerParams { localIp?: string; hostname?: string; - gpuFreeMb?: number; branchId?: string; location?: ServerLocation; tier?: ServerTier; + maxApps?: number; } export interface FindServersFilters { @@ -55,10 +55,10 @@ export const serverService = { localIp: params.localIp, hostname: params.hostname, type: params.type, - gpuFreeMb: params.gpuFreeMb, branchId: params.branchId, location: params.location, tier: tier, + maxApps: params.maxApps, }) .returning(); @@ -141,7 +141,6 @@ export const serverService = { const streamServers = await db.query.servers.findMany({ where: and(...conditions), - orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)], }); return streamServers; @@ -159,7 +158,6 @@ export const serverService = { const localServers = await db.query.servers.findMany({ where: and(...conditions), - orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)], }); return localServers; @@ -181,10 +179,6 @@ export const serverService = { updateData.hostname = params.hostname; } - if (params.gpuFreeMb !== undefined) { - updateData.gpuFreeMb = params.gpuFreeMb; - } - if (params.branchId !== undefined) { updateData.branchId = params.branchId; } @@ -197,6 +191,10 @@ export const serverService = { updateData.tier = params.tier; } + if (params.maxApps !== undefined) { + updateData.maxApps = params.maxApps; + } + const [updatedServer] = await db .update(servers) .set(updateData) @@ -206,22 +204,6 @@ export const serverService = { return updatedServer; }, - /** - * Обновить свободную память GPU - */ - async updateGpuMemory(serverId: string, gpuFreeMb: number) { - const [updatedServer] = await db - .update(servers) - .set({ - gpuFreeMb, - updatedAt: new Date(), - }) - .where(eq(servers.id, serverId)) - .returning(); - - return updatedServer; - }, - /** * Удалить сервер */ diff --git a/server/src/services/serverSession/index.ts b/server/src/services/serverSession/index.ts index a40d1ab..863a8fc 100644 --- a/server/src/services/serverSession/index.ts +++ b/server/src/services/serverSession/index.ts @@ -250,7 +250,37 @@ export const serverSessionService = { // Валидация: должен быть указан либо userId, либо guestId if (!userId && !guestId) { - throw new Error("Either userId or guestId must be provided"); + throw new Error("Необходимо указать userId или guestId"); + } + + // Получить информацию о приложении для проверки лимита + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + + if (!app) { + throw new Error("Приложение не найдено"); + } + + // Проверить лимит на количество запущенных экземпляров приложения + // Подсчитываем активные сессии для этого приложения + // Активными считаются сессии со статусом "started" или "starting" + const activeSessions = await db.query.serverSessions.findMany({ + where: and( + eq(serverSessions.appId, appId), + or( + eq(serverSessions.status, "started"), + eq(serverSessions.status, "starting") + ) + ), + }); + + const activeCount = activeSessions.length; + + if (activeCount >= app.maxInstances) { + throw new Error( + `Достигнут лимит запущенных экземпляров приложения "${app.title}" (${activeCount}/${app.maxInstances}). Пожалуйста, подождите, пока освободится место.` + ); } // Для local-сессий выбираем сервер сразу @@ -259,7 +289,7 @@ export const serverSessionService = { if (mode === "local" && !selectedServerId) { selectedServerId = await this.selectAvailableServer(mode); if (!selectedServerId) { - throw new Error(`No available ${mode} servers`); + throw new Error(`Нет доступных серверов для режима ${mode}`); } } @@ -354,7 +384,7 @@ export const serverSessionService = { const session = await this.findById(sessionId); if (!session) { - throw new Error("Session not found"); + throw new Error("Сессия не найдена"); } const newEndAt = session.endAt ? new Date(session.endAt) : new Date(); @@ -374,13 +404,13 @@ export const serverSessionService = { /** * Назначить сервер для сессии - * Выбирает сервер с максимальной свободной GPU памятью + * Выбирает сервер на основе максимального количества запущенных приложений из БД */ - async assignServer(sessionId: string, requiredGpuMb?: number) { + async assignServer(sessionId: string) { const session = await this.findById(sessionId); if (!session) { - throw new Error("Session not found"); + throw new Error("Сессия не найдена"); } if (session.serverId) { @@ -394,7 +424,7 @@ export const serverSessionService = { let selectedServer; if (session.mode === "stream") { - // Для stream-сессий выбираем сервер с максимальной свободной памятью + // Для stream-сессий выбираем сервер // Приоритет: tier из сессии > demo для неавторизованных > все серверы для авторизованных const tier = session.tier || (session.userId ? undefined : "demo"); const availableServers = await serverService.findAvailableStreamServers(tier); @@ -402,70 +432,40 @@ export const serverSessionService = { if (availableServers.length === 0) { const serverType = tier === "demo" ? "demo " : ""; throw new Error( - `No available ${serverType}stream servers (check that stream servers are registered)` + `Нет доступных ${serverType}stream серверов (проверьте, что stream серверы зарегистрированы)` ); } console.log( `[${new Date().toISOString()}] 📊 Найдено ${ availableServers.length - } stream-серверов:`, - availableServers - .map((s) => `${s.hostname} (${s.tier}, ${s.gpuFreeMb}MB)`) - .join(", ") + } stream-серверов` ); - // Фильтруем серверы по доступной GPU памяти - // Требуемая память берется из gpuLimitMb приложения или используются все доступные серверы - const memoryOkServers = requiredGpuMb - ? availableServers.filter((s) => { - const hasEnough = s.gpuFreeMb >= requiredGpuMb; - if (!hasEnough) { - console.log( - `[${new Date().toISOString()}] ⚠️ Сервер ${s.id} (${ - s.hostname - }) пропущен по памяти: ${s.gpuFreeMb}MB < ${requiredGpuMb}MB` - ); - } - return hasEnough; - }) - : availableServers; - - if (memoryOkServers.length === 0) { - const maxAvailable = Math.max( - ...availableServers.map((s) => s.gpuFreeMb) - ); - throw new Error( - `No servers with enough GPU memory (required: ${requiredGpuMb}MB, max available: ${maxAvailable}MB)` - ); - } - // Проверяем количество активных сессий на каждом сервере - // Максимум одновременных сессий на один stream-сервер (по умолчанию 3) - const MAX_SESSIONS_PER_SERVER = parseInt( - process.env.MAX_SESSIONS_PER_STREAM_SERVER || "3", - 10 - ); const suitableServers = []; - for (const server of memoryOkServers) { - // Подсчитываем активные сессии (starting или started) + for (const server of availableServers) { + // Максимальное количество запущенных приложений на сервере (по умолчанию 1) + const maxApps = server.maxApps ?? 1; + + // Подсчитываем активные сессии на сервере (starting или started) const activeSessions = await this.findByServerId(server.id, {}); const activeCount = activeSessions.filter( (s) => s.status === "starting" || s.status === "started" ).length; - if (activeCount >= MAX_SESSIONS_PER_SERVER) { + if (activeCount >= maxApps) { console.log( `[${new Date().toISOString()}] ⚠️ Сервер ${server.id} (${ server.hostname - }) пропущен по загрузке: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий` + }) пропущен: ${activeCount}/${maxApps} активных сессий` ); } else { console.log( `[${new Date().toISOString()}] ✅ Сервер ${server.id} (${ server.hostname - }) доступен: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий` + }) доступен: ${activeCount}/${maxApps} активных сессий` ); suitableServers.push(server); } @@ -473,33 +473,65 @@ export const serverSessionService = { if (suitableServers.length === 0) { throw new Error( - `No available servers (all servers have ${MAX_SESSIONS_PER_SERVER} or more active sessions)` + `Нет доступных серверов (все серверы достигли лимита активных сессий)` ); } - // Берем первый сервер (уже отсортирован по убыванию gpuFreeMb) + // Берем первый подходящий сервер selectedServer = suitableServers[0]; console.log( `[${new Date().toISOString()}] ✅ Выбран сервер ${selectedServer.id} (${ selectedServer.hostname - }) с ${ - selectedServer.gpuFreeMb - }MB свободной памяти для сессии ${sessionId} (требуется: ${ - requiredGpuMb || "не указано" - }MB)` + }) для сессии ${sessionId}` ); } else { - // Для local-сессий используем существующую логику - const serverId = await this.selectAvailableServer(session.mode); - if (!serverId) { - throw new Error("No available local servers"); + // Для local-сессий проверяем лимит на доступных серверах + const availableLocalServers = await serverService.findAvailableLocalServers(); + + if (availableLocalServers.length === 0) { + throw new Error("Нет доступных local серверов"); } - selectedServer = await serverService.findById(serverId); + + const suitableServers = []; + + for (const server of availableLocalServers) { + // Максимальное количество запущенных приложений на сервере (по умолчанию 1) + const maxApps = server.maxApps ?? 1; + + // Подсчитываем активные сессии на сервере (starting или started) + const activeSessions = await this.findByServerId(server.id, {}); + const activeCount = activeSessions.filter( + (s) => s.status === "starting" || s.status === "started" + ).length; + + if (activeCount >= maxApps) { + console.log( + `[${new Date().toISOString()}] ⚠️ Local сервер ${server.id} (${ + server.hostname + }) пропущен: ${activeCount}/${maxApps} активных сессий` + ); + } else { + console.log( + `[${new Date().toISOString()}] ✅ Local сервер ${server.id} (${ + server.hostname + }) доступен: ${activeCount}/${maxApps} активных сессий` + ); + suitableServers.push(server); + } + } + + if (suitableServers.length === 0) { + throw new Error( + `Нет доступных local серверов (все серверы достигли лимита активных сессий)` + ); + } + + selectedServer = suitableServers[0]; } if (!selectedServer) { - throw new Error("Failed to select server"); + throw new Error("Не удалось выбрать сервер"); } // Назначаем сервер сессии @@ -550,16 +582,13 @@ export const serverSessionService = { // Назначаем сервер для каждой готовой сессии for (const session of readySessions) { try { - const requiredGpuMb = session.app.gpuLimitMb || undefined; console.log( `[${new Date().toISOString()}] 🔍 Назначение сервера для сессии ${ session.id - } (приложение: ${session.app.name}, требуется GPU: ${ - requiredGpuMb || "не указано" - }MB)` + } (приложение: ${session.app.name})` ); - await this.assignServer(session.id, requiredGpuMb); + await this.assignServer(session.id); results.assigned++; } catch (error) { results.failed++; diff --git a/session-server/src/index.ts b/session-server/src/index.ts index 54079dc..60accfc 100644 --- a/session-server/src/index.ts +++ b/session-server/src/index.ts @@ -40,10 +40,6 @@ const REGISTER_INTERVAL_MS = parseInt( process.env.REGISTER_INTERVAL_MS || "30000", 10 ); // 30 секунд по умолчанию -const GPU_UPDATE_INTERVAL_MS = parseInt( - process.env.GPU_UPDATE_INTERVAL_MS || "1000", - 10 -); // 1 секунда по умолчанию const SESSION_CHECK_INTERVAL_MS = parseInt( process.env.SESSION_CHECK_INTERVAL_MS || "1000", 10 @@ -68,7 +64,6 @@ interface ServerRegistrationData { localIp: string; hostname: string; type: "stream" | "local"; - gpuFreeMb: number; branchId?: string; location?: "ru1" | "uae1"; tier?: "demo" | "prod"; @@ -80,7 +75,6 @@ interface ServerRegistrationResponse { localIp: string; hostname: string; type: "stream" | "local"; - gpuFreeMb: number; branchId?: string; location?: "ru1" | "uae1"; tier?: "demo" | "prod"; @@ -112,6 +106,7 @@ interface SessionData { title: string; gpuLimitMb: number | null; psVersion: number | null; + maxInstances: number | null; }; user: { id: string; @@ -177,49 +172,6 @@ function findFreePort( }); } -/** - * Получить свободную память GPU через nvidia-smi - * Возвращает количество свободной памяти в МБ - * Выбрасывает ошибку, если не удалось получить данные - */ -function getGpuFreeMb(): number { - try { - // Выполняем nvidia-smi с форматом вывода только свободной памяти - // --query-gpu=memory.free - запрашиваем свободную память - // --format=csv,noheader,nounits - CSV формат без заголовков и единиц измерения - const output = execSync( - "nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits", - { - encoding: "utf-8", - timeout: 5000, // 5 секунд таймаут - windowsHide: true, // Скрыть окно консоли на Windows - stdio: "pipe", // Перенаправить вывод в pipe вместо создания нового окна - } - ); - - // Парсим вывод (может быть несколько GPU, берём первый) - const lines = output.trim().split("\n"); - if (lines.length > 0 && lines[0]) { - const freeMb = parseInt(lines[0].trim(), 10); - if (!isNaN(freeMb) && freeMb >= 0) { - return freeMb; - } - } - - throw new Error("Не удалось распарсить вывод nvidia-smi"); - } catch (error) { - console.error( - `[${new Date().toISOString()}] ❌ Критическая ошибка при получении данных GPU:`, - error instanceof Error ? error.message : error - ); - throw new Error( - `Невозможно получить данные о GPU через nvidia-smi: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } -} - /** * Регистрация сервера на главном сервере */ @@ -243,14 +195,10 @@ async function registerServer(isRecursive: boolean = false): Promise { // Установить tier по умолчанию для stream-серверов const tier = SERVER_TYPE === "stream" && !SERVER_TIER ? "demo" : SERVER_TIER; - // Получаем актуальное значение свободной GPU памяти - const gpuFreeMb = getGpuFreeMb(); - const registrationData: ServerRegistrationData = { localIp: LOCAL_IP, hostname: HOSTNAME, type: SERVER_TYPE, - gpuFreeMb: gpuFreeMb, branchId: BRANCH_ID, location: SERVER_LOCATION, tier: tier, @@ -288,9 +236,8 @@ async function registerServer(isRecursive: boolean = false): Promise { ); } - // При первом запуске запускаем обновление GPU и проверку сессий после успешной регистрации + // При первом запуске запускаем проверку сессий после успешной регистрации if (!isRecursive && SERVER_ID) { - updateGpuMemory(); checkSessions(); } } catch (error: unknown) { @@ -313,40 +260,6 @@ async function registerServer(isRecursive: boolean = false): Promise { setTimeout(() => registerServer(true), REGISTER_INTERVAL_MS); } -/** - * Обновить информацию о свободной памяти GPU на сервере - */ -async function updateGpuMemory(): Promise { - try { - // Получаем актуальное значение свободной GPU памяти - const gpuFreeMb = getGpuFreeMb(); - - await got.patch(`${API_URL}/servers/${SERVER_ID}/gpu`, { - json: { gpuFreeMb }, - timeout: { - request: 5000, // 5 секунд таймаут - }, - retry: { - limit: 2, - methods: ["PATCH"], - statusCodes: [408, 429, 500, 502, 503, 504], - }, - }); - - console.log( - `[${new Date().toISOString()}] 🎮 GPU память обновлена: ${gpuFreeMb} MB` - ); - } catch (error: unknown) { - console.error( - `[${new Date().toISOString()}] ❌ Ошибка обновления GPU памяти:`, - error instanceof RequestError ? error.message : error - ); - } - - // Планируем следующее обновление после завершения текущего - setTimeout(() => updateGpuMemory(), GPU_UPDATE_INTERVAL_MS); -} - /** * Получить сессии для этого сервера */ @@ -978,18 +891,15 @@ async function main() { console.log(` Hostname: ${HOSTNAME}`); console.log(` Local IP: ${LOCAL_IP}`); console.log(` Type: ${SERVER_TYPE}`); - console.log(` GPU Free MB: ${getGpuFreeMb()} (читается из nvidia-smi)`); if (SERVER_LOCATION) console.log(` Location: ${SERVER_LOCATION}`); if (SERVER_TIER) console.log(` Tier: ${SERVER_TIER}`); if (BRANCH_ID) console.log(` Branch ID: ${BRANCH_ID}`); console.log(` Register Interval: ${REGISTER_INTERVAL_MS}ms`); - console.log(` GPU Update Interval: ${GPU_UPDATE_INTERVAL_MS}ms`); console.log(` Session Check Interval: ${SESSION_CHECK_INTERVAL_MS}ms`); console.log("=".repeat(60)); // Запуск рекурсивной регистрации // Использует setTimeout вместо setInterval, чтобы избежать наложения запросов - // После первой успешной регистрации запускается обновление GPU await registerServer(); }