Remove outdated documentation files for companies and migration guide; implement server session management features including server assignment and session status updates; enhance database schema for servers and server sessions with new fields and validation; add auto-assign functionality for unassigned sessions.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { authMiddleware } from "../middlewares/auth";
|
||||
import { serverService } from "../services/server";
|
||||
|
||||
export const serverController = new Elysia({ prefix: "/servers" })
|
||||
// POST /servers/register - публичный endpoint для регистрации сервера (без авторизации)
|
||||
.post(
|
||||
"/register",
|
||||
async ({ body, set }) => {
|
||||
const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } =
|
||||
body as {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Валидация для stream-серверов
|
||||
if (type === "stream") {
|
||||
if (!location) {
|
||||
set.status = 400;
|
||||
return { error: "Location is required for stream servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для local-серверов
|
||||
if (type === "local") {
|
||||
if (!branchId) {
|
||||
set.status = 400;
|
||||
return { error: "Branch ID is required for local servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const finalTier = type === "stream" && !tier ? "demo" : tier;
|
||||
|
||||
// Проверить, существует ли сервер с таким hostname
|
||||
const existingServer = await serverService.findByHostname(hostname);
|
||||
|
||||
if (existingServer) {
|
||||
// Если сервер существует, обновить его информацию
|
||||
const updatedServer = await serverService.update(existingServer.id, {
|
||||
localIp,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier: finalTier,
|
||||
});
|
||||
|
||||
return { server: updatedServer, registered: false };
|
||||
}
|
||||
|
||||
// Создать новый сервер
|
||||
const server = await serverService.create({
|
||||
localIp,
|
||||
hostname,
|
||||
type,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier: finalTier,
|
||||
});
|
||||
|
||||
return { server, registered: true };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
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;
|
||||
const { statusFilter, mode } = query as {
|
||||
statusFilter?: "starting" | "started" | "ending" | "ended";
|
||||
mode?: "stream" | "local";
|
||||
};
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
const { serverSessionService } = await import("../services/serverSession");
|
||||
|
||||
// Получаем только сессии, назначенные этому серверу
|
||||
// Main server автоматически назначает серверы для unassigned сессий
|
||||
const sessions = await serverSessionService.findByServerId(id, {
|
||||
status: statusFilter,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { sessions };
|
||||
})
|
||||
// Все остальные роуты требуют авторизации
|
||||
.use(authMiddleware)
|
||||
// GET /servers - получить список серверов с фильтрацией
|
||||
.get("/", async ({ query }) => {
|
||||
const { type, location, tier, branchId } = query as {
|
||||
type?: "stream" | "local";
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
branchId?: string;
|
||||
};
|
||||
|
||||
const servers = await serverService.findAll({
|
||||
type,
|
||||
location,
|
||||
tier,
|
||||
branchId,
|
||||
});
|
||||
|
||||
return { servers };
|
||||
})
|
||||
// GET /servers/available/stream - получить доступные stream-серверы
|
||||
.get("/available/stream", async ({ query }) => {
|
||||
const { tier } = query as { tier?: "demo" | "prod" };
|
||||
|
||||
const servers = await serverService.findAvailableStreamServers(tier);
|
||||
|
||||
return { servers };
|
||||
})
|
||||
// GET /servers/available/local - получить доступные local-серверы
|
||||
.get("/available/local", async ({ query }) => {
|
||||
const { branchId } = query as { branchId?: string };
|
||||
|
||||
const servers = await serverService.findAvailableLocalServers(branchId);
|
||||
|
||||
return { servers };
|
||||
})
|
||||
// GET /servers/:id - получить сервер по ID
|
||||
.get("/:id", async ({ params, status }) => {
|
||||
const { id } = params;
|
||||
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
return { server };
|
||||
})
|
||||
// POST /servers - создать сервер
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, set }) => {
|
||||
const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } =
|
||||
body as {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Валидация для stream-серверов
|
||||
if (type === "stream") {
|
||||
if (!location) {
|
||||
set.status = 400;
|
||||
return { error: "Location is required for stream servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для local-серверов
|
||||
if (type === "local") {
|
||||
if (!branchId) {
|
||||
set.status = 400;
|
||||
return { error: "Branch ID is required for local servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const finalTier = type === "stream" && !tier ? "demo" : tier;
|
||||
|
||||
const server = await serverService.create({
|
||||
localIp,
|
||||
hostname,
|
||||
type,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier: finalTier,
|
||||
});
|
||||
|
||||
return { server };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
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 - обновить сервер
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, status, set }) => {
|
||||
const { id } = params;
|
||||
const { localIp, hostname, gpuFreeMb, branchId, location, tier } =
|
||||
body as {
|
||||
localIp?: string;
|
||||
hostname?: string;
|
||||
gpuFreeMb?: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
// Валидация для stream-серверов: нельзя удалить location
|
||||
if (server.type === "stream") {
|
||||
if (location === undefined && server.location === null) {
|
||||
set.status = 400;
|
||||
return { error: "Location cannot be removed from stream servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для local-серверов: нельзя удалить branchId
|
||||
if (server.type === "local") {
|
||||
if (branchId === undefined && server.branchId === null) {
|
||||
set.status = 400;
|
||||
return { error: "Branch ID cannot be removed from local servers" };
|
||||
}
|
||||
}
|
||||
|
||||
const updatedServer = await serverService.update(id, {
|
||||
localIp,
|
||||
hostname,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier,
|
||||
});
|
||||
|
||||
return { server: updatedServer };
|
||||
},
|
||||
{
|
||||
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")])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// DELETE /servers/:id - удалить сервер
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
const { id } = params;
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
await serverService.delete(id);
|
||||
|
||||
return { message: "Server deleted successfully" };
|
||||
});
|
||||
@@ -6,6 +6,78 @@ import { apps } from "../db/schema/apps";
|
||||
import { serverSessionService } from "../services/serverSession";
|
||||
|
||||
export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
// PATCH /sessions/:id/status - обновить статус сессии (публичный endpoint для сессионного сервера)
|
||||
.patch(
|
||||
"/:id/status",
|
||||
async ({ params, body, status }) => {
|
||||
const { id } = params;
|
||||
const {
|
||||
status: sessionStatus,
|
||||
appPid,
|
||||
cirrusPid,
|
||||
} = body as {
|
||||
status?: "starting" | "started" | "ending" | "ended";
|
||||
appPid?: number;
|
||||
cirrusPid?: number;
|
||||
};
|
||||
|
||||
// Проверить, что сессия существует
|
||||
const session = await serverSessionService.findById(id);
|
||||
|
||||
if (!session) {
|
||||
return status(404, "Session not found");
|
||||
}
|
||||
|
||||
// Обновить сессию
|
||||
const updatedSession = await serverSessionService.update(id, {
|
||||
status: sessionStatus,
|
||||
appPid,
|
||||
cirrusPid,
|
||||
});
|
||||
|
||||
return { session: updatedSession };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("starting"),
|
||||
t.Literal("started"),
|
||||
t.Literal("ending"),
|
||||
t.Literal("ended"),
|
||||
])
|
||||
),
|
||||
appPid: t.Optional(t.Number()),
|
||||
cirrusPid: t.Optional(t.Number()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// POST /sessions/:id/assign-server - назначить сервер для сессии (публичный endpoint для сессионного сервера)
|
||||
.post(
|
||||
"/:id/assign-server",
|
||||
async ({ params, body, status }) => {
|
||||
const { id } = params;
|
||||
const { requiredGpuMb } = body as { requiredGpuMb?: number };
|
||||
|
||||
try {
|
||||
const updatedSession = await serverSessionService.assignServer(
|
||||
id,
|
||||
requiredGpuMb
|
||||
);
|
||||
return { session: updatedSession };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return status(400, error.message);
|
||||
}
|
||||
return status(500, "Failed to assign server");
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
requiredGpuMb: t.Optional(t.Number({ minimum: 0 })),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// Все роуты требуют авторизации
|
||||
.use(authMiddleware)
|
||||
// GET /sessions - получить список сессий пользователя
|
||||
|
||||
@@ -16,17 +16,17 @@ export const sessionStatusEnum = pgEnum("session_status", [
|
||||
|
||||
export const serverSessions = pgTable("server_sessions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
serverId: uuid("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id),
|
||||
serverId: uuid("server_id").references(() => servers.id), // Nullable - для stream сессий назначается динамически
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
startAt: timestamp("start_at").defaultNow().notNull(),
|
||||
endAt: timestamp("end_at"), // Default 30 minutes from start_at
|
||||
startAt: timestamp("start_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
endAt: timestamp("end_at", { withTimezone: true }), // Default 30 minutes from start_at
|
||||
appPid: integer("app_pid"),
|
||||
cirrusPid: integer("cirrus_pid"),
|
||||
mode: sessionModeEnum("mode").notNull(), // stream, local
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { pgTable, uuid, varchar, pgEnum, timestamp } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
pgEnum,
|
||||
timestamp,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
@@ -14,6 +21,7 @@ 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)
|
||||
@@ -26,7 +34,20 @@ export const servers = pgTable("servers", {
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertServerSchema = createInsertSchema(servers);
|
||||
export const insertServerSchema = createInsertSchema(servers).refine(
|
||||
(data) => {
|
||||
// Если тип "stream", то location и tier обязательны
|
||||
if (data.type === "stream") {
|
||||
return data.location !== undefined && data.location !== null;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Location is required for stream servers",
|
||||
path: ["location"],
|
||||
}
|
||||
);
|
||||
|
||||
export const selectServerSchema = createSelectSchema(servers);
|
||||
|
||||
// Relations
|
||||
|
||||
@@ -4,6 +4,8 @@ import { authController } from "./controllers/auth";
|
||||
import { sessionController } from "./controllers/session";
|
||||
import { companyController } from "./controllers/company";
|
||||
import { branchController } from "./controllers/branch";
|
||||
import { serverController } from "./controllers/server";
|
||||
import { serverSessionService } from "./services/serverSession";
|
||||
|
||||
const app = new Elysia();
|
||||
|
||||
@@ -18,9 +20,53 @@ app.use(authController);
|
||||
app.use(sessionController);
|
||||
app.use(companyController);
|
||||
app.use(branchController);
|
||||
app.use(serverController);
|
||||
|
||||
app.listen(3000);
|
||||
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
|
||||
);
|
||||
|
||||
// Запуск фоновой задачи для автоматического назначения серверов
|
||||
const AUTO_ASSIGN_INTERVAL_MS = parseInt(
|
||||
process.env.AUTO_ASSIGN_INTERVAL_MS || "1000",
|
||||
10
|
||||
); // 1 секунда по умолчанию
|
||||
|
||||
async function autoAssignServersTask() {
|
||||
try {
|
||||
const results = await serverSessionService.autoAssignServers();
|
||||
|
||||
if (results.total > 0) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🎯 Auto-assign: ${
|
||||
results.assigned
|
||||
} assigned, ${results.failed} failed из ${results.total}`
|
||||
);
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибки назначения:`,
|
||||
results.errors
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка auto-assign:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем следующий запуск
|
||||
setTimeout(autoAssignServersTask, AUTO_ASSIGN_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Запускаем через 1 секунду после старта сервера
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🤖 Запуск фоновой задачи auto-assign (интервал: ${AUTO_ASSIGN_INTERVAL_MS}ms)`
|
||||
);
|
||||
autoAssignServersTask();
|
||||
}, 1000);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import db from "../../db";
|
||||
import { servers } from "../../db/schema/servers";
|
||||
|
||||
export type ServerType = "stream" | "local";
|
||||
export type ServerLocation = "ru1" | "uae1";
|
||||
export type ServerTier = "demo" | "prod";
|
||||
|
||||
export interface CreateServerParams {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: ServerType;
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: ServerLocation;
|
||||
tier?: ServerTier;
|
||||
}
|
||||
|
||||
export interface UpdateServerParams {
|
||||
localIp?: string;
|
||||
hostname?: string;
|
||||
gpuFreeMb?: number;
|
||||
branchId?: string;
|
||||
location?: ServerLocation;
|
||||
tier?: ServerTier;
|
||||
}
|
||||
|
||||
export interface FindServersFilters {
|
||||
type?: ServerType;
|
||||
location?: ServerLocation;
|
||||
tier?: ServerTier;
|
||||
branchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сервис для работы с серверами
|
||||
*/
|
||||
export const serverService = {
|
||||
/**
|
||||
* Создать сервер
|
||||
*/
|
||||
async create(params: CreateServerParams) {
|
||||
// Валидация для stream-серверов
|
||||
if (params.type === "stream" && !params.location) {
|
||||
throw new Error("Location is required for stream servers");
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const tier =
|
||||
params.type === "stream" && !params.tier ? "demo" : params.tier;
|
||||
|
||||
const [server] = await db
|
||||
.insert(servers)
|
||||
.values({
|
||||
localIp: params.localIp,
|
||||
hostname: params.hostname,
|
||||
type: params.type,
|
||||
gpuFreeMb: params.gpuFreeMb,
|
||||
branchId: params.branchId,
|
||||
location: params.location,
|
||||
tier: tier,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return server;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить все серверы с фильтрацией
|
||||
*/
|
||||
async findAll(filters?: FindServersFilters) {
|
||||
const conditions = [];
|
||||
|
||||
if (filters?.type) {
|
||||
conditions.push(eq(servers.type, filters.type));
|
||||
}
|
||||
|
||||
if (filters?.location) {
|
||||
conditions.push(eq(servers.location, filters.location));
|
||||
}
|
||||
|
||||
if (filters?.tier) {
|
||||
conditions.push(eq(servers.tier, filters.tier));
|
||||
}
|
||||
|
||||
if (filters?.branchId) {
|
||||
conditions.push(eq(servers.branchId, filters.branchId));
|
||||
}
|
||||
|
||||
const allServers = await db.query.servers.findMany({
|
||||
where: conditions.length > 0 ? and(...conditions) : undefined,
|
||||
orderBy: (servers, { asc }) => [asc(servers.hostname)],
|
||||
});
|
||||
|
||||
return allServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Найти сервер по ID
|
||||
*/
|
||||
async findById(serverId: string) {
|
||||
const server = await db.query.servers.findFirst({
|
||||
where: eq(servers.id, serverId),
|
||||
});
|
||||
|
||||
return server || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Найти сервер по hostname
|
||||
*/
|
||||
async findByHostname(hostname: string) {
|
||||
const server = await db.query.servers.findFirst({
|
||||
where: eq(servers.hostname, hostname),
|
||||
});
|
||||
|
||||
return server || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить серверы по филиалу
|
||||
*/
|
||||
async findByBranchId(branchId: string) {
|
||||
const branchServers = await db.query.servers.findMany({
|
||||
where: eq(servers.branchId, branchId),
|
||||
orderBy: (servers, { asc }) => [asc(servers.hostname)],
|
||||
});
|
||||
|
||||
return branchServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить доступные stream-серверы
|
||||
*/
|
||||
async findAvailableStreamServers(tier?: ServerTier) {
|
||||
const conditions = [eq(servers.type, "stream")];
|
||||
|
||||
if (tier) {
|
||||
conditions.push(eq(servers.tier, tier));
|
||||
}
|
||||
|
||||
const streamServers = await db.query.servers.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)],
|
||||
});
|
||||
|
||||
return streamServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить доступные local-серверы
|
||||
*/
|
||||
async findAvailableLocalServers(branchId?: string) {
|
||||
const conditions = [eq(servers.type, "local")];
|
||||
|
||||
if (branchId) {
|
||||
conditions.push(eq(servers.branchId, branchId));
|
||||
}
|
||||
|
||||
const localServers = await db.query.servers.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)],
|
||||
});
|
||||
|
||||
return localServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновить сервер
|
||||
*/
|
||||
async update(serverId: string, params: UpdateServerParams) {
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (params.localIp) {
|
||||
updateData.localIp = params.localIp;
|
||||
}
|
||||
|
||||
if (params.hostname) {
|
||||
updateData.hostname = params.hostname;
|
||||
}
|
||||
|
||||
if (params.gpuFreeMb !== undefined) {
|
||||
updateData.gpuFreeMb = params.gpuFreeMb;
|
||||
}
|
||||
|
||||
if (params.branchId !== undefined) {
|
||||
updateData.branchId = params.branchId;
|
||||
}
|
||||
|
||||
if (params.location !== undefined) {
|
||||
updateData.location = params.location;
|
||||
}
|
||||
|
||||
if (params.tier !== undefined) {
|
||||
updateData.tier = params.tier;
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(servers)
|
||||
.set(updateData)
|
||||
.where(eq(servers.id, serverId))
|
||||
.returning();
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Удалить сервер
|
||||
*/
|
||||
async delete(serverId: string) {
|
||||
await db.delete(servers).where(eq(servers.id, serverId));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,6 +120,80 @@ export const serverSessionService = {
|
||||
return sessions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить все сессии для конкретного сервера
|
||||
*/
|
||||
async findByServerId(
|
||||
serverId: string,
|
||||
filters?: {
|
||||
status?: SessionStatus;
|
||||
mode?: SessionMode;
|
||||
}
|
||||
) {
|
||||
const conditions = [eq(serverSessions.serverId, serverId)];
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(serverSessions.status, filters.status));
|
||||
}
|
||||
|
||||
if (filters?.mode) {
|
||||
conditions.push(eq(serverSessions.mode, filters.mode));
|
||||
}
|
||||
|
||||
const sessions = await db.query.serverSessions.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
app: true,
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (serverSessions, { asc }) => [asc(serverSessions.createdAt)],
|
||||
});
|
||||
|
||||
return sessions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить сессии без назначенного сервера
|
||||
*/
|
||||
async findUnassignedSessions(filters?: {
|
||||
status?: SessionStatus;
|
||||
mode?: SessionMode;
|
||||
}) {
|
||||
const { isNull } = await import("drizzle-orm");
|
||||
const conditions = [isNull(serverSessions.serverId)];
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(serverSessions.status, filters.status));
|
||||
}
|
||||
|
||||
if (filters?.mode) {
|
||||
conditions.push(eq(serverSessions.mode, filters.mode));
|
||||
}
|
||||
|
||||
const sessions = await db.query.serverSessions.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
app: true,
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (serverSessions, { asc }) => [asc(serverSessions.createdAt)],
|
||||
});
|
||||
|
||||
return sessions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Проверить, есть ли у пользователя активная сессия для данного приложения
|
||||
*/
|
||||
@@ -161,9 +235,10 @@ export const serverSessionService = {
|
||||
async create(params: CreateSessionParams) {
|
||||
const { appId, userId, mode, serverId } = params;
|
||||
|
||||
// Выбрать сервер (если не указан)
|
||||
// Для local-сессий выбираем сервер сразу
|
||||
// Для stream-сессий сервер будет назначен динамически при запуске
|
||||
let selectedServerId = serverId;
|
||||
if (!selectedServerId) {
|
||||
if (mode === "local" && !selectedServerId) {
|
||||
selectedServerId = await this.selectAvailableServer(mode);
|
||||
if (!selectedServerId) {
|
||||
throw new Error(`No available ${mode} servers`);
|
||||
@@ -178,7 +253,7 @@ export const serverSessionService = {
|
||||
const [newSession] = await db
|
||||
.insert(serverSessions)
|
||||
.values({
|
||||
serverId: selectedServerId,
|
||||
serverId: selectedServerId, // Может быть null для stream-сессий
|
||||
appId,
|
||||
userId,
|
||||
mode,
|
||||
@@ -263,4 +338,210 @@ export const serverSessionService = {
|
||||
|
||||
return updatedSession;
|
||||
},
|
||||
|
||||
/**
|
||||
* Назначить сервер для сессии
|
||||
* Выбирает сервер с максимальной свободной GPU памятью
|
||||
*/
|
||||
async assignServer(sessionId: string, requiredGpuMb?: number) {
|
||||
const session = await this.findById(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (session.serverId) {
|
||||
// Сервер уже назначен
|
||||
return session;
|
||||
}
|
||||
|
||||
// Импортируем serverService динамически чтобы избежать циклических зависимостей
|
||||
const { serverService } = await import("../server");
|
||||
|
||||
let selectedServer;
|
||||
|
||||
if (session.mode === "stream") {
|
||||
// Для stream-сессий выбираем сервер с максимальной свободной памятью
|
||||
// Ищем среди всех tier (prod и demo)
|
||||
const availableServers = await serverService.findAvailableStreamServers();
|
||||
|
||||
if (availableServers.length === 0) {
|
||||
throw new Error(
|
||||
"No available stream servers (check that stream servers are registered)"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 📊 Найдено ${
|
||||
availableServers.length
|
||||
} 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 = [];
|
||||
|
||||
for (const server of memoryOkServers) {
|
||||
// Подсчитываем активные сессии (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) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Сервер ${server.id} (${
|
||||
server.hostname
|
||||
}) пропущен по загрузке: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Сервер ${server.id} (${
|
||||
server.hostname
|
||||
}) доступен: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий`
|
||||
);
|
||||
suitableServers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
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)`
|
||||
);
|
||||
} else {
|
||||
// Для local-сессий используем существующую логику
|
||||
const serverId = await this.selectAvailableServer(session.mode);
|
||||
if (!serverId) {
|
||||
throw new Error("No available local servers");
|
||||
}
|
||||
selectedServer = await serverService.findById(serverId);
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
throw new Error("Failed to select server");
|
||||
}
|
||||
|
||||
// Назначаем сервер сессии
|
||||
const [updatedSession] = await db
|
||||
.update(serverSessions)
|
||||
.set({
|
||||
serverId: selectedServer.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(serverSessions.id, sessionId))
|
||||
.returning();
|
||||
|
||||
return updatedSession;
|
||||
},
|
||||
|
||||
/**
|
||||
* Автоматически назначить серверы для всех unassigned сессий, готовых к запуску
|
||||
* Вызывается периодически main сервером
|
||||
*/
|
||||
async autoAssignServers() {
|
||||
const now = new Date();
|
||||
|
||||
// Находим все unassigned сессии со статусом "starting" и startAt <= now
|
||||
const { isNull } = await import("drizzle-orm");
|
||||
const unassignedSessions = await db.query.serverSessions.findMany({
|
||||
where: and(
|
||||
isNull(serverSessions.serverId),
|
||||
eq(serverSessions.status, "starting")
|
||||
),
|
||||
with: {
|
||||
app: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Фильтруем сессии, у которых уже наступило время запуска
|
||||
const readySessions = unassignedSessions.filter((session) => {
|
||||
const startAt = new Date(session.startAt);
|
||||
return startAt <= now;
|
||||
});
|
||||
|
||||
const results = {
|
||||
total: readySessions.length,
|
||||
assigned: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Назначаем сервер для каждой готовой сессии
|
||||
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)`
|
||||
);
|
||||
|
||||
await this.assignServer(session.id, requiredGpuMb);
|
||||
results.assigned++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
results.errors.push(
|
||||
`Session ${session.id} (${session.app.name}): ${errorMsg}`
|
||||
);
|
||||
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Не удалось назначить сервер для сессии ${
|
||||
session.id
|
||||
}:`,
|
||||
errorMsg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user