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:
2025-10-06 15:59:55 +05:00
parent 9e4bc7b0f8
commit a49129f643
16 changed files with 2332 additions and 483 deletions
-376
View File
@@ -1,376 +0,0 @@
# Компании и филиалы - Документация
## Обзор
Добавлена функциональность для управления компаниями и филиалами с поддержкой привязки пользователей к нескольким филиалам.
## Структура базы данных
### Таблицы
#### `companies` - Компании
- `id` - UUID, первичный ключ
- `name` - Название компании (обязательное)
- `description` - Описание компании
- `createdAt` - Дата создания
- `updatedAt` - Дата обновления
#### `branches` - Филиалы
- `id` - UUID, первичный ключ
- `companyId` - UUID, внешний ключ на компанию (каскадное удаление)
- `name` - Название филиала (обязательное)
- `address` - Адрес филиала
- `city` - Город
- `country` - Страна
- `createdAt` - Дата создания
- `updatedAt` - Дата обновления
#### `user_branches` - Связь пользователей и филиалов (Many-to-Many)
- `userId` - UUID, внешний ключ на пользователя (каскадное удаление)
- `branchId` - UUID, внешний ключ на филиал (каскадное удаление)
- `createdAt` - Дата создания
- Составной первичный ключ: `(userId, branchId)`
### Изменения в таблице `users`
Добавлено поле:
- `currentBranchId` - UUID, внешний ключ на выбранный филиал (nullable)
## API Эндпоинты
Все эндпоинты требуют авторизации через Bearer токен.
### Компании (`/companies`)
#### `GET /companies`
Получить список всех компаний с их филиалами.
**Ответ:**
```json
{
"companies": [
{
"id": "uuid",
"name": "Компания 1",
"description": "Описание",
"branches": [...],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
]
}
```
#### `GET /companies/:id`
Получить компанию по ID.
**Параметры:**
- `id` - UUID компании
**Ответ:**
```json
{
"company": {
"id": "uuid",
"name": "Компания 1",
"description": "Описание",
"branches": [...],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
}
```
#### `POST /companies`
Создать новую компанию.
**Body:**
```json
{
"name": "Новая компания",
"description": "Описание (опционально)"
}
```
**Ответ:**
```json
{
"company": {
"id": "uuid",
"name": "Новая компания",
"description": "Описание",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
}
```
#### `PATCH /companies/:id`
Обновить компанию.
**Body:**
```json
{
"name": "Обновленное название (опционально)",
"description": "Обновленное описание (опционально)"
}
```
#### `DELETE /companies/:id`
Удалить компанию (каскадно удалит все филиалы).
**Ответ:**
```json
{
"message": "Company deleted successfully"
}
```
### Филиалы (`/branches`)
#### `GET /branches/my`
Получить филиалы текущего пользователя.
**Ответ:**
```json
{
"branches": [
{
"id": "uuid",
"companyId": "uuid",
"name": "Филиал 1",
"address": "Адрес",
"city": "Город",
"country": "Страна",
"company": {...},
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
]
}
```
#### `GET /branches/:id`
Получить филиал по ID.
**Ответ:**
```json
{
"branch": {
"id": "uuid",
"companyId": "uuid",
"name": "Филиал 1",
"address": "Адрес",
"city": "Город",
"country": "Страна",
"company": {...},
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
}
```
#### `GET /branches/:id/users`
Получить пользователей филиала.
**Ответ:**
```json
{
"users": [
{
"id": "uuid",
"email": "user@example.com",
"fullName": "Иван Иванов",
"role": "manager",
"currentBranchId": "uuid"
}
]
}
```
#### `POST /branches`
Создать новый филиал.
**Body:**
```json
{
"companyId": "uuid",
"name": "Новый филиал",
"address": "Адрес (опционально)",
"city": "Город (опционально)",
"country": "Страна (опционально)"
}
```
#### `PATCH /branches/:id`
Обновить филиал.
**Body:**
```json
{
"name": "Обновленное название (опционально)",
"address": "Обновленный адрес (опционально)",
"city": "Обновленный город (опционально)",
"country": "Обновленная страна (опционально)"
}
```
#### `DELETE /branches/:id`
Удалить филиал.
#### `POST /branches/:id/users`
Привязать пользователя к филиалу.
**Body:**
```json
{
"userId": "uuid"
}
```
**Ответ:**
```json
{
"message": "User assigned to branch successfully"
}
```
#### `DELETE /branches/:id/users/:userId`
Отвязать пользователя от филиала.
**Ответ:**
```json
{
"message": "User removed from branch successfully"
}
```
#### `POST /branches/:id/select`
Установить филиал как текущий для авторизованного пользователя.
**Ответ:**
```json
{
"message": "Current branch updated successfully",
"currentBranchId": "uuid"
}
```
**Ошибки:**
- `400` - Пользователь не привязан к этому филиалу
## Использование
### Создание компании и филиалов
```typescript
// 1. Создать компанию
POST /companies
{
"name": "ООО Рога и Копыта",
"description": "Крупная IT компания"
}
// 2. Создать филиалы
POST /branches
{
"companyId": "company-uuid",
"name": "Московский офис",
"city": "Москва",
"country": "Россия"
}
POST /branches
{
"companyId": "company-uuid",
"name": "Питерский офис",
"city": "Санкт-Петербург",
"country": "Россия"
}
```
### Привязка пользователя к филиалам
```typescript
// Привязать пользователя к московскому офису
POST /branches/{moscow-branch-id}/users
{
"userId": "user-uuid"
}
// Привязать пользователя к питерскому офису
POST /branches/{spb-branch-id}/users
{
"userId": "user-uuid"
}
```
### Выбор текущего филиала
```typescript
// Установить московский офис как текущий
POST /branches/{moscow-branch-id}/select
// Теперь в объекте пользователя currentBranchId будет указывать на московский офис
```
### Получение филиалов пользователя
```typescript
// Получить все филиалы, к которым привязан пользователь
GET /branches/my
```
## Миграция базы данных
После добавления схем необходимо выполнить миграцию:
```bash
cd server
bun run drizzle-kit generate
bun run drizzle-kit migrate
```
## Типы TypeScript
### Company
```typescript
type Company = {
id: string;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
};
```
### Branch
```typescript
type Branch = {
id: string;
companyId: string;
name: string;
address: string | null;
city: string | null;
country: string | null;
createdAt: Date;
updatedAt: Date;
};
```
### UserBranch
```typescript
type UserBranch = {
userId: string;
branchId: string;
createdAt: Date;
};
```
## Примечания
1. **Каскадное удаление**: При удалении компании автоматически удаляются все её филиалы
2. **Каскадное удаление связей**: При удалении пользователя или филиала автоматически удаляются все связи в таблице `user_branches`
3. **Валидация**: Пользователь может установить текущим только тот филиал, к которому он привязан
4. **Many-to-Many**: Пользователь может быть привязан к нескольким филиалам одновременно
5. **Текущий филиал**: Поле `currentBranchId` в таблице пользователей позволяет отслеживать, в каком филиале пользователь работает в данный момент
-97
View File
@@ -1,97 +0,0 @@
# Руководство по миграции: Упрощение системы ролей
## Изменения
Мы удаляем отдельную таблицу `roles` и переносим роли прямо в таблицу `users`.
### Изменения в схеме:
1. **Таблица `users`**:
- Переименование колонки `role_name``role`
- Удаление внешнего ключа на таблицу `roles`
- Добавление значения по умолчанию `'manager'`
2. **Таблица `roles`**:
- Удаление всей таблицы (больше не нужна)
## Шаги миграции
### 1. Создать миграцию через Drizzle Kit
```bash
cd server
bun run db:generate
```
### 2. Применить миграцию
```bash
bun run db:migrate
```
### 3. Ручные SQL-команды (если нужно)
Если автоматическая миграция не сработает, выполните следующие команды вручную:
```sql
-- 1. Удалить внешний ключ из таблицы users
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_name_roles_name_fk;
-- 2. Переименовать колонку role_name в role
ALTER TABLE users RENAME COLUMN role_name TO role;
-- 3. Добавить значение по умолчанию
ALTER TABLE users ALTER COLUMN role SET DEFAULT 'manager';
-- 4. Удалить таблицу roles
DROP TABLE IF EXISTS roles;
```
## Откат миграции (если нужно)
Если что-то пошло не так, можно откатить изменения:
```sql
-- 1. Создать таблицу roles заново
CREATE TABLE roles (
name TEXT PRIMARY KEY,
title VARCHAR(100) NOT NULL
);
-- 2. Заполнить таблицу roles
INSERT INTO roles (name, title) VALUES
('admin', 'Администратор'),
('director', 'Директор'),
('manager', 'Менеджер');
-- 3. Переименовать колонку обратно
ALTER TABLE users RENAME COLUMN role TO role_name;
-- 4. Удалить значение по умолчанию
ALTER TABLE users ALTER COLUMN role_name DROP DEFAULT;
-- 5. Добавить внешний ключ
ALTER TABLE users ADD CONSTRAINT users_role_name_roles_name_fk
FOREIGN KEY (role_name) REFERENCES roles(name);
```
## Проверка
После миграции проверьте:
1. Все существующие пользователи сохранили свои роли
2. API `/auth/me` возвращает корректный формат:
```json
{
"user": {
"id": "...",
"email": "...",
"fullName": "...",
"role": "manager",
"createdAt": "..."
}
}
```
3. Регистрация и логин работают корректно
4. Проверка прав доступа работает (middleware)
+311
View File
@@ -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" };
});
+72
View File
@@ -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 - получить список сессий пользователя
+5 -5
View File
@@ -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
+23 -2
View File
@@ -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
+46
View File
@@ -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);
+232
View File
@@ -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));
},
};
+284 -3
View File
@@ -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;
},
};