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.
This commit is contained in:
2025-12-05 18:04:01 +05:00
parent 775ba52cd0
commit c384087f38
13 changed files with 234 additions and 254 deletions
+29 -5
View File
@@ -16,6 +16,7 @@
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-router": "^7.9.3", "react-router": "^7.9.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
@@ -30,6 +31,7 @@
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.2",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
@@ -1905,11 +1907,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.13", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.13.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.2.tgz",
"integrity": "sha512-7s16KR8io8nIBWQyCYhmFhd+ebIzb9VKTzki+wOJXHTxTnV6+mFGH3+Jwn1zoKaY9/H9T/0BcKCZnzXljPnpSQ==", "integrity": "sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
@@ -2168,7 +2169,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -2810,6 +2810,14 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -3687,6 +3695,22 @@
"react": "^19.2.0" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+1
View File
@@ -33,6 +33,7 @@
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.2",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
+63
View File
@@ -0,0 +1,63 @@
import { HTTPError } from "ky";
/**
* Извлекает сообщение об ошибке из ответа API
* Elysia возвращает ошибки как строку в теле ответа или как JSON с полем message
*/
export async function extractErrorMessage(error: unknown): Promise<string> {
// Если это 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<number, string> = {
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 "Произошла неизвестная ошибка";
}
+14 -3
View File
@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import toast from "react-hot-toast";
import { extractErrorMessage } from "../lib/errorUtils";
interface Session { interface Session {
id: string; id: string;
@@ -44,9 +46,18 @@ function TestPage() {
navigate(`/sessions/${response.session.id}`); navigate(`/sessions/${response.session.id}`);
} catch (err) { } catch (err) {
console.error("Failed to start session:", 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
+1
View File
@@ -22,6 +22,7 @@ export interface Session {
title: string; title: string;
gpuLimitMb: number | null; gpuLimitMb: number | null;
psVersion: number | null; psVersion: number | null;
maxInstances: number | null;
}; };
server?: { server?: {
id: string; id: string;
+2 -1
View File
@@ -2,7 +2,8 @@ module.exports = {
apps: [ apps: [
{ {
name: "stream.graff.estate-server", name: "stream.graff.estate-server",
script: "bun ./dist", interpreter: "bun",
script: "./dist/index.js",
}, },
], ],
}; };
+2 -35
View File
@@ -44,7 +44,6 @@ export const serverController = new Elysia({ prefix: "/servers" })
// Если сервер существует, обновить его информацию // Если сервер существует, обновить его информацию
const updatedServer = await serverService.update(existingServer.id, { const updatedServer = await serverService.update(existingServer.id, {
localIp, localIp,
gpuFreeMb,
branchId, branchId,
location, location,
tier: finalTier, tier: finalTier,
@@ -58,7 +57,6 @@ export const serverController = new Elysia({ prefix: "/servers" })
localIp, localIp,
hostname, hostname,
type, type,
gpuFreeMb,
branchId, branchId,
location, location,
tier: finalTier, tier: finalTier,
@@ -71,37 +69,12 @@ export const serverController = new Elysia({ prefix: "/servers" })
localIp: t.String({ minLength: 7, maxLength: 45 }), localIp: t.String({ minLength: 7, maxLength: 45 }),
hostname: t.String({ minLength: 1, maxLength: 255 }), hostname: t.String({ minLength: 1, maxLength: 255 }),
type: t.Union([t.Literal("stream"), t.Literal("local")]), type: t.Union([t.Literal("stream"), t.Literal("local")]),
gpuFreeMb: t.Number({ minimum: 0 }),
branchId: t.Optional(t.String({ format: "uuid" })), branchId: t.Optional(t.String({ format: "uuid" })),
location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])),
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), 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 /servers/:id/sessions - получить сессии для конкретного сервера (публичный endpoint)
.get("/:id/sessions", async ({ params, query, status }) => { .get("/:id/sessions", async ({ params, query, status }) => {
const { id } = params; const { id } = params;
@@ -180,12 +153,11 @@ export const serverController = new Elysia({ prefix: "/servers" })
.post( .post(
"/", "/",
async ({ body, set }) => { async ({ body, set }) => {
const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } = const { localIp, hostname, type, branchId, location, tier } =
body as { body as {
localIp: string; localIp: string;
hostname: string; hostname: string;
type: "stream" | "local"; type: "stream" | "local";
gpuFreeMb: number;
branchId?: string; branchId?: string;
location?: "ru1" | "uae1"; location?: "ru1" | "uae1";
tier?: "demo" | "prod"; tier?: "demo" | "prod";
@@ -214,7 +186,6 @@ export const serverController = new Elysia({ prefix: "/servers" })
localIp, localIp,
hostname, hostname,
type, type,
gpuFreeMb,
branchId, branchId,
location, location,
tier: finalTier, tier: finalTier,
@@ -227,7 +198,6 @@ export const serverController = new Elysia({ prefix: "/servers" })
localIp: t.String({ minLength: 7, maxLength: 45 }), localIp: t.String({ minLength: 7, maxLength: 45 }),
hostname: t.String({ minLength: 1, maxLength: 255 }), hostname: t.String({ minLength: 1, maxLength: 255 }),
type: t.Union([t.Literal("stream"), t.Literal("local")]), type: t.Union([t.Literal("stream"), t.Literal("local")]),
gpuFreeMb: t.Number({ minimum: 0 }),
branchId: t.Optional(t.String({ format: "uuid" })), branchId: t.Optional(t.String({ format: "uuid" })),
location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])),
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
@@ -239,11 +209,10 @@ export const serverController = new Elysia({ prefix: "/servers" })
"/:id", "/:id",
async ({ params, body, status, set }) => { async ({ params, body, status, set }) => {
const { id } = params; const { id } = params;
const { localIp, hostname, gpuFreeMb, branchId, location, tier } = const { localIp, hostname, branchId, location, tier } =
body as { body as {
localIp?: string; localIp?: string;
hostname?: string; hostname?: string;
gpuFreeMb?: number;
branchId?: string; branchId?: string;
location?: "ru1" | "uae1"; location?: "ru1" | "uae1";
tier?: "demo" | "prod"; tier?: "demo" | "prod";
@@ -275,7 +244,6 @@ export const serverController = new Elysia({ prefix: "/servers" })
const updatedServer = await serverService.update(id, { const updatedServer = await serverService.update(id, {
localIp, localIp,
hostname, hostname,
gpuFreeMb,
branchId, branchId,
location, location,
tier, tier,
@@ -287,7 +255,6 @@ export const serverController = new Elysia({ prefix: "/servers" })
body: t.Object({ body: t.Object({
localIp: t.Optional(t.String({ minLength: 7, maxLength: 45 })), localIp: t.Optional(t.String({ minLength: 7, maxLength: 45 })),
hostname: t.Optional(t.String({ minLength: 1, maxLength: 255 })), hostname: t.Optional(t.String({ minLength: 1, maxLength: 255 })),
gpuFreeMb: t.Optional(t.Number({ minimum: 0 })),
branchId: t.Optional(t.String({ format: "uuid" })), branchId: t.Optional(t.String({ format: "uuid" })),
location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])),
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
+17 -26
View File
@@ -33,7 +33,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
const session = await serverSessionService.findById(id); const session = await serverSessionService.findById(id);
if (!session) { 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 /sessions/:id/assign-server - назначить сервер для сессии (публичный endpoint для сессионного сервера)
.post( .post(
"/:id/assign-server", "/:id/assign-server",
async ({ params, body, status }) => { async ({ params, status }) => {
const { id } = params; const { id } = params;
const { requiredGpuMb } = body as { requiredGpuMb?: number };
try { try {
const updatedSession = await serverSessionService.assignServer( const updatedSession = await serverSessionService.assignServer(id);
id,
requiredGpuMb
);
return { session: updatedSession }; return { session: updatedSession };
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
return status(400, error.message); 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 (доступны для неавторизованных пользователей) // Endpoints с optional auth (доступны для неавторизованных пользователей)
@@ -111,26 +102,26 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
}); });
if (!app) { if (!app) {
return status(404, "App not found"); return status(404, "Приложение не найдено");
} }
// Если пользователь не авторизован // Если пользователь не авторизован
if (!currentUser) { if (!currentUser) {
// Проверяем наличие guestId для неавторизованных пользователей // Проверяем наличие guestId для неавторизованных пользователей
if (!guestId) { if (!guestId) {
return status(400, "Guest ID is required for unauthorized users"); return status(400, "Для неавторизованных пользователей требуется Guest ID");
} }
// Проверяем, что режим - stream (только stream поддерживает demo) // Проверяем, что режим - stream (только stream поддерживает demo)
if (mode !== "stream") { if (mode !== "stream") {
return status(401, "Authorization required for local sessions"); return status(401, "Для local сессий требуется авторизация");
} }
// Неавторизованные пользователи могут использовать только demo-серверы // Неавторизованные пользователи могут использовать только demo-серверы
if (tier && tier !== "demo") { if (tier && tier !== "demo") {
return status( return status(
403, 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) { if (demoServers.length === 0) {
return status( return status(
503, 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) { if (error instanceof Error) {
return status(503, error.message); 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) { if (hasActive) {
return status(409, "User already has an active session for this app"); return status(409, "У вас уже есть активная сессия для этого приложения");
} }
// Для режима stream - проверяем наличие серверов нужного tier // Для режима stream - проверяем наличие серверов нужного tier
@@ -182,7 +173,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
tier tier
); );
if (availableServers.length === 0) { 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) { if (error instanceof Error) {
return status(503, error.message); 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) { if (!session) {
return status(404, "Session not found"); return status(404, "Сессия не найдена");
} }
// Обновить сессию // Обновить сессию
@@ -344,12 +335,12 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
); );
if (!session) { if (!session) {
return status(404, "Session not found"); return status(404, "Сессия не найдена");
} }
// Проверить, что сессия активна // Проверить, что сессия активна
if (session.status !== "started") { 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) { if (error instanceof Error) {
return status(400, error.message); return status(400, error.message);
} }
return status(500, "Failed to extend session"); return status(500, "Не удалось продлить сессию");
} }
}, },
{ {
+1 -1
View File
@@ -13,8 +13,8 @@ export const apps = pgTable("apps", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name").notNull(), // Имя приложения (например, "minecraft") name: varchar("name").notNull(), // Имя приложения (например, "minecraft")
title: varchar("title").notNull(), // Название приложения (например, "Майнкрафт") title: varchar("title").notNull(), // Название приложения (например, "Майнкрафт")
gpuLimitMb: integer("gpu_limit_mb"), // Лимит GPU в мегабайтах (только для stream серверов)
psVersion: integer("ps_version"), // Версия Pixel Streaming (например, "1") psVersion: integer("ps_version"), // Версия Pixel Streaming (например, "1")
maxInstances: integer("max_instances").default(1).notNull(), // Лимит на количество одновременно запущенных экземпляров приложения
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
+1 -1
View File
@@ -19,10 +19,10 @@ export const servers = pgTable("servers", {
localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars
hostname: varchar("hostname").notNull(), // hostname сервера hostname: varchar("hostname").notNull(), // hostname сервера
type: serverTypeEnum("type").notNull(), // stream, local type: serverTypeEnum("type").notNull(), // stream, local
gpuFreeMb: integer("gpu_free_mb").notNull(), // свободная память на GPU в мегабайтах
branchId: uuid("branch_id").references(() => branches.id), // филиал, на котором находится сервер (nullable для локальных серверов) branchId: uuid("branch_id").references(() => branches.id), // филиал, на котором находится сервер (nullable для локальных серверов)
location: serverLocationEnum("location"), // ru1, uae1 (только для stream) location: serverLocationEnum("location"), // ru1, uae1 (только для stream)
tier: serverTierEnum("tier"), // demo, prod (только для stream) tier: serverTierEnum("tier"), // demo, prod (только для stream)
maxApps: integer("max_apps").default(1).notNull(), // Максимальное количество запущенных приложений на сервере
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
+7 -25
View File
@@ -10,19 +10,19 @@ export interface CreateServerParams {
localIp: string; localIp: string;
hostname: string; hostname: string;
type: ServerType; type: ServerType;
gpuFreeMb: number;
branchId?: string; branchId?: string;
location?: ServerLocation; location?: ServerLocation;
tier?: ServerTier; tier?: ServerTier;
maxApps?: number;
} }
export interface UpdateServerParams { export interface UpdateServerParams {
localIp?: string; localIp?: string;
hostname?: string; hostname?: string;
gpuFreeMb?: number;
branchId?: string; branchId?: string;
location?: ServerLocation; location?: ServerLocation;
tier?: ServerTier; tier?: ServerTier;
maxApps?: number;
} }
export interface FindServersFilters { export interface FindServersFilters {
@@ -55,10 +55,10 @@ export const serverService = {
localIp: params.localIp, localIp: params.localIp,
hostname: params.hostname, hostname: params.hostname,
type: params.type, type: params.type,
gpuFreeMb: params.gpuFreeMb,
branchId: params.branchId, branchId: params.branchId,
location: params.location, location: params.location,
tier: tier, tier: tier,
maxApps: params.maxApps,
}) })
.returning(); .returning();
@@ -141,7 +141,6 @@ export const serverService = {
const streamServers = await db.query.servers.findMany({ const streamServers = await db.query.servers.findMany({
where: and(...conditions), where: and(...conditions),
orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)],
}); });
return streamServers; return streamServers;
@@ -159,7 +158,6 @@ export const serverService = {
const localServers = await db.query.servers.findMany({ const localServers = await db.query.servers.findMany({
where: and(...conditions), where: and(...conditions),
orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)],
}); });
return localServers; return localServers;
@@ -181,10 +179,6 @@ export const serverService = {
updateData.hostname = params.hostname; updateData.hostname = params.hostname;
} }
if (params.gpuFreeMb !== undefined) {
updateData.gpuFreeMb = params.gpuFreeMb;
}
if (params.branchId !== undefined) { if (params.branchId !== undefined) {
updateData.branchId = params.branchId; updateData.branchId = params.branchId;
} }
@@ -197,6 +191,10 @@ export const serverService = {
updateData.tier = params.tier; updateData.tier = params.tier;
} }
if (params.maxApps !== undefined) {
updateData.maxApps = params.maxApps;
}
const [updatedServer] = await db const [updatedServer] = await db
.update(servers) .update(servers)
.set(updateData) .set(updateData)
@@ -206,22 +204,6 @@ export const serverService = {
return updatedServer; 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;
},
/** /**
* Удалить сервер * Удалить сервер
*/ */
+94 -65
View File
@@ -250,7 +250,37 @@ export const serverSessionService = {
// Валидация: должен быть указан либо userId, либо guestId // Валидация: должен быть указан либо userId, либо guestId
if (!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-сессий выбираем сервер сразу // Для local-сессий выбираем сервер сразу
@@ -259,7 +289,7 @@ export const serverSessionService = {
if (mode === "local" && !selectedServerId) { if (mode === "local" && !selectedServerId) {
selectedServerId = await this.selectAvailableServer(mode); selectedServerId = await this.selectAvailableServer(mode);
if (!selectedServerId) { 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); const session = await this.findById(sessionId);
if (!session) { if (!session) {
throw new Error("Session not found"); throw new Error("Сессия не найдена");
} }
const newEndAt = session.endAt ? new Date(session.endAt) : new Date(); 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); const session = await this.findById(sessionId);
if (!session) { if (!session) {
throw new Error("Session not found"); throw new Error("Сессия не найдена");
} }
if (session.serverId) { if (session.serverId) {
@@ -394,7 +424,7 @@ export const serverSessionService = {
let selectedServer; let selectedServer;
if (session.mode === "stream") { if (session.mode === "stream") {
// Для stream-сессий выбираем сервер с максимальной свободной памятью // Для stream-сессий выбираем сервер
// Приоритет: tier из сессии > demo для неавторизованных > все серверы для авторизованных // Приоритет: tier из сессии > demo для неавторизованных > все серверы для авторизованных
const tier = session.tier || (session.userId ? undefined : "demo"); const tier = session.tier || (session.userId ? undefined : "demo");
const availableServers = await serverService.findAvailableStreamServers(tier); const availableServers = await serverService.findAvailableStreamServers(tier);
@@ -402,70 +432,40 @@ export const serverSessionService = {
if (availableServers.length === 0) { if (availableServers.length === 0) {
const serverType = tier === "demo" ? "demo " : ""; const serverType = tier === "demo" ? "demo " : "";
throw new Error( throw new Error(
`No available ${serverType}stream servers (check that stream servers are registered)` `Нет доступных ${serverType}stream серверов (проверьте, что stream серверы зарегистрированы)`
); );
} }
console.log( console.log(
`[${new Date().toISOString()}] 📊 Найдено ${ `[${new Date().toISOString()}] 📊 Найдено ${
availableServers.length availableServers.length
} stream-серверов:`, } stream-серверов`
availableServers
.map((s) => `${s.hostname} (${s.tier}, ${s.gpuFreeMb}MB)`)
.join(", ")
); );
// Фильтруем серверы по доступной 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 = []; const suitableServers = [];
for (const server of memoryOkServers) { for (const server of availableServers) {
// Подсчитываем активные сессии (starting или started) // Максимальное количество запущенных приложений на сервере (по умолчанию 1)
const maxApps = server.maxApps ?? 1;
// Подсчитываем активные сессии на сервере (starting или started)
const activeSessions = await this.findByServerId(server.id, {}); const activeSessions = await this.findByServerId(server.id, {});
const activeCount = activeSessions.filter( const activeCount = activeSessions.filter(
(s) => s.status === "starting" || s.status === "started" (s) => s.status === "starting" || s.status === "started"
).length; ).length;
if (activeCount >= MAX_SESSIONS_PER_SERVER) { if (activeCount >= maxApps) {
console.log( console.log(
`[${new Date().toISOString()}] ⚠️ Сервер ${server.id} (${ `[${new Date().toISOString()}] ⚠️ Сервер ${server.id} (${
server.hostname server.hostname
}) пропущен по загрузке: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий` }) пропущен: ${activeCount}/${maxApps} активных сессий`
); );
} else { } else {
console.log( console.log(
`[${new Date().toISOString()}] ✅ Сервер ${server.id} (${ `[${new Date().toISOString()}] ✅ Сервер ${server.id} (${
server.hostname server.hostname
}) доступен: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий` }) доступен: ${activeCount}/${maxApps} активных сессий`
); );
suitableServers.push(server); suitableServers.push(server);
} }
@@ -473,33 +473,65 @@ export const serverSessionService = {
if (suitableServers.length === 0) { if (suitableServers.length === 0) {
throw new Error( throw new Error(
`No available servers (all servers have ${MAX_SESSIONS_PER_SERVER} or more active sessions)` `Нет доступных серверов (все серверы достигли лимита активных сессий)`
); );
} }
// Берем первый сервер (уже отсортирован по убыванию gpuFreeMb) // Берем первый подходящий сервер
selectedServer = suitableServers[0]; selectedServer = suitableServers[0];
console.log( console.log(
`[${new Date().toISOString()}] ✅ Выбран сервер ${selectedServer.id} (${ `[${new Date().toISOString()}] ✅ Выбран сервер ${selectedServer.id} (${
selectedServer.hostname selectedServer.hostname
}) с ${ }) для сессии ${sessionId}`
selectedServer.gpuFreeMb
}MB свободной памяти для сессии ${sessionId} (требуется: ${
requiredGpuMb || "не указано"
}MB)`
); );
} else { } else {
// Для local-сессий используем существующую логику // Для local-сессий проверяем лимит на доступных серверах
const serverId = await this.selectAvailableServer(session.mode); const availableLocalServers = await serverService.findAvailableLocalServers();
if (!serverId) {
throw new Error("No available local servers"); 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) { if (!selectedServer) {
throw new Error("Failed to select server"); throw new Error("Не удалось выбрать сервер");
} }
// Назначаем сервер сессии // Назначаем сервер сессии
@@ -550,16 +582,13 @@ export const serverSessionService = {
// Назначаем сервер для каждой готовой сессии // Назначаем сервер для каждой готовой сессии
for (const session of readySessions) { for (const session of readySessions) {
try { try {
const requiredGpuMb = session.app.gpuLimitMb || undefined;
console.log( console.log(
`[${new Date().toISOString()}] 🔍 Назначение сервера для сессии ${ `[${new Date().toISOString()}] 🔍 Назначение сервера для сессии ${
session.id session.id
} (приложение: ${session.app.name}, требуется GPU: ${ } (приложение: ${session.app.name})`
requiredGpuMb || "не указано"
}MB)`
); );
await this.assignServer(session.id, requiredGpuMb); await this.assignServer(session.id);
results.assigned++; results.assigned++;
} catch (error) { } catch (error) {
results.failed++; results.failed++;
+2 -92
View File
@@ -40,10 +40,6 @@ const REGISTER_INTERVAL_MS = parseInt(
process.env.REGISTER_INTERVAL_MS || "30000", process.env.REGISTER_INTERVAL_MS || "30000",
10 10
); // 30 секунд по умолчанию ); // 30 секунд по умолчанию
const GPU_UPDATE_INTERVAL_MS = parseInt(
process.env.GPU_UPDATE_INTERVAL_MS || "1000",
10
); // 1 секунда по умолчанию
const SESSION_CHECK_INTERVAL_MS = parseInt( const SESSION_CHECK_INTERVAL_MS = parseInt(
process.env.SESSION_CHECK_INTERVAL_MS || "1000", process.env.SESSION_CHECK_INTERVAL_MS || "1000",
10 10
@@ -68,7 +64,6 @@ interface ServerRegistrationData {
localIp: string; localIp: string;
hostname: string; hostname: string;
type: "stream" | "local"; type: "stream" | "local";
gpuFreeMb: number;
branchId?: string; branchId?: string;
location?: "ru1" | "uae1"; location?: "ru1" | "uae1";
tier?: "demo" | "prod"; tier?: "demo" | "prod";
@@ -80,7 +75,6 @@ interface ServerRegistrationResponse {
localIp: string; localIp: string;
hostname: string; hostname: string;
type: "stream" | "local"; type: "stream" | "local";
gpuFreeMb: number;
branchId?: string; branchId?: string;
location?: "ru1" | "uae1"; location?: "ru1" | "uae1";
tier?: "demo" | "prod"; tier?: "demo" | "prod";
@@ -112,6 +106,7 @@ interface SessionData {
title: string; title: string;
gpuLimitMb: number | null; gpuLimitMb: number | null;
psVersion: number | null; psVersion: number | null;
maxInstances: number | null;
}; };
user: { user: {
id: string; 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<void> {
// Установить tier по умолчанию для stream-серверов // Установить tier по умолчанию для stream-серверов
const tier = SERVER_TYPE === "stream" && !SERVER_TIER ? "demo" : SERVER_TIER; const tier = SERVER_TYPE === "stream" && !SERVER_TIER ? "demo" : SERVER_TIER;
// Получаем актуальное значение свободной GPU памяти
const gpuFreeMb = getGpuFreeMb();
const registrationData: ServerRegistrationData = { const registrationData: ServerRegistrationData = {
localIp: LOCAL_IP, localIp: LOCAL_IP,
hostname: HOSTNAME, hostname: HOSTNAME,
type: SERVER_TYPE, type: SERVER_TYPE,
gpuFreeMb: gpuFreeMb,
branchId: BRANCH_ID, branchId: BRANCH_ID,
location: SERVER_LOCATION, location: SERVER_LOCATION,
tier: tier, tier: tier,
@@ -288,9 +236,8 @@ async function registerServer(isRecursive: boolean = false): Promise<void> {
); );
} }
// При первом запуске запускаем обновление GPU и проверку сессий после успешной регистрации // При первом запуске запускаем проверку сессий после успешной регистрации
if (!isRecursive && SERVER_ID) { if (!isRecursive && SERVER_ID) {
updateGpuMemory();
checkSessions(); checkSessions();
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -313,40 +260,6 @@ async function registerServer(isRecursive: boolean = false): Promise<void> {
setTimeout(() => registerServer(true), REGISTER_INTERVAL_MS); setTimeout(() => registerServer(true), REGISTER_INTERVAL_MS);
} }
/**
* Обновить информацию о свободной памяти GPU на сервере
*/
async function updateGpuMemory(): Promise<void> {
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(` Hostname: ${HOSTNAME}`);
console.log(` Local IP: ${LOCAL_IP}`); console.log(` Local IP: ${LOCAL_IP}`);
console.log(` Type: ${SERVER_TYPE}`); console.log(` Type: ${SERVER_TYPE}`);
console.log(` GPU Free MB: ${getGpuFreeMb()} (читается из nvidia-smi)`);
if (SERVER_LOCATION) console.log(` Location: ${SERVER_LOCATION}`); if (SERVER_LOCATION) console.log(` Location: ${SERVER_LOCATION}`);
if (SERVER_TIER) console.log(` Tier: ${SERVER_TIER}`); if (SERVER_TIER) console.log(` Tier: ${SERVER_TIER}`);
if (BRANCH_ID) console.log(` Branch ID: ${BRANCH_ID}`); if (BRANCH_ID) console.log(` Branch ID: ${BRANCH_ID}`);
console.log(` Register Interval: ${REGISTER_INTERVAL_MS}ms`); 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(` Session Check Interval: ${SESSION_CHECK_INTERVAL_MS}ms`);
console.log("=".repeat(60)); console.log("=".repeat(60));
// Запуск рекурсивной регистрации // Запуск рекурсивной регистрации
// Использует setTimeout вместо setInterval, чтобы избежать наложения запросов // Использует setTimeout вместо setInterval, чтобы избежать наложения запросов
// После первой успешной регистрации запускается обновление GPU
await registerServer(); await registerServer();
} }