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",
"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",
+1
View File
@@ -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",
+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 { 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);
}
+1
View File
@@ -22,6 +22,7 @@ export interface Session {
title: string;
gpuLimitMb: number | null;
psVersion: number | null;
maxInstances: number | null;
};
server?: {
id: string;
+2 -1
View File
@@ -2,7 +2,8 @@ module.exports = {
apps: [
{
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, {
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")])),
+17 -26
View File
@@ -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, "Не удалось продлить сессию");
}
},
{
+1 -1
View File
@@ -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(),
+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
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(),
+7 -25
View File
@@ -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;
},
/**
* Удалить сервер
*/
+94 -65
View File
@@ -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++;
+2 -92
View File
@@ -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<void> {
// Установить 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<void> {
);
}
// При первом запуске запускаем обновление GPU и проверку сессий после успешной регистрации
// При первом запуске запускаем проверку сессий после успешной регистрации
if (!isRecursive && SERVER_ID) {
updateGpuMemory();
checkSessions();
}
} catch (error: unknown) {
@@ -313,40 +260,6 @@ async function registerServer(isRecursive: boolean = false): Promise<void> {
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(` 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();
}