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
+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++;