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:
Generated
+29
-5
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "Произошла неизвестная ошибка";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Session {
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
maxInstances: number | null;
|
||||
};
|
||||
server?: {
|
||||
id: string;
|
||||
|
||||
@@ -2,7 +2,8 @@ module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "stream.graff.estate-server",
|
||||
script: "bun ./dist",
|
||||
interpreter: "bun",
|
||||
script: "./dist/index.js",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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")])),
|
||||
|
||||
@@ -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, "Не удалось продлить сессию");
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Удалить сервер
|
||||
*/
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user