Add SessionInfoFloat component to SessionPage and enhance session management in server
- Introduced SessionInfoFloat component to display session details when a session is active. - Updated session server logic to automatically transition sessions to "ending" status when their end time is reached. - Improved process termination handling with enhanced error checking and fallback mechanisms. - Refactored application stop logic to handle multiple processes more efficiently and added timeout management for process termination.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { Session } from "../../types/Session";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface SessionInfoFloatProps {
|
||||
session: Session;
|
||||
}
|
||||
|
||||
function SessionInfoFloat({ session }: SessionInfoFloatProps) {
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>("");
|
||||
const [isVisible, setIsVisible] = useState<boolean>(true);
|
||||
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
||||
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Таймер обратного отсчета
|
||||
useEffect(() => {
|
||||
const calculateTimeRemaining = () => {
|
||||
if (!session.endAt) {
|
||||
setTimeRemaining("--:--");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().getTime();
|
||||
const endTime = new Date(session.endAt).getTime();
|
||||
const diff = endTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeRemaining("00:00");
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
if (hours > 0) {
|
||||
setTimeRemaining(
|
||||
`${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
|
||||
);
|
||||
} else {
|
||||
setTimeRemaining(
|
||||
`${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
calculateTimeRemaining();
|
||||
const interval = setInterval(calculateTimeRemaining, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [session.endAt]);
|
||||
|
||||
// Автоматическое скрытие через 3 секунды
|
||||
useEffect(() => {
|
||||
hideTimeoutRef.current = setTimeout(() => {
|
||||
setIsAnimating(true);
|
||||
setIsVisible(false);
|
||||
|
||||
// Разблокируем после завершения анимации (500ms)
|
||||
animationTimeoutRef.current = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 500);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
}
|
||||
if (animationTimeoutRef.current) {
|
||||
clearTimeout(animationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Обработчики наведения на компонент
|
||||
const handleComponentMouseEnter = () => {
|
||||
// Игнорируем события во время анимации
|
||||
if (isAnimating) return;
|
||||
|
||||
// Отменяем таймер скрытия если он есть
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Показываем компонент если он скрыт
|
||||
if (!isVisible) {
|
||||
setIsAnimating(true);
|
||||
setIsVisible(true);
|
||||
|
||||
// Разблокируем после завершения анимации (500ms)
|
||||
animationTimeoutRef.current = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentMouseLeave = () => {
|
||||
// Игнорируем события во время анимации
|
||||
if (isAnimating) return;
|
||||
|
||||
// Скрываем компонент сразу при уходе курсора
|
||||
setIsAnimating(true);
|
||||
setIsVisible(false);
|
||||
|
||||
// Разблокируем после завершения анимации (300ms)
|
||||
animationTimeoutRef.current = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col items-center relative transition-all duration-300 ease-out",
|
||||
"2xl:py-[1.111vw] py-4", // Padding сверху вместо top в родителе
|
||||
!isVisible && "-translate-y-[calc(100%-max(1.667vw,24px))]"
|
||||
)}
|
||||
onMouseEnter={handleComponentMouseEnter}
|
||||
onMouseLeave={handleComponentMouseLeave}
|
||||
>
|
||||
{/* Main Container with shadow - animates in/out */}
|
||||
<div
|
||||
className="flex flex-row items-center 2xl:gap-[1.111vw] gap-4 2xl:p-[0.556vw] p-2 bg-white 2xl:rounded-[1.389vw] rounded-[20px] transition-opacity duration-500 ease-out 2xl:mb-[0.556vw] mb-2"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 4px 40px 0px rgba(15, 16, 17, 0.1), 0px 2px 2px 0px rgba(0, 0, 0, 0.06)",
|
||||
// opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Left Section: Image + Text */}
|
||||
<div className="flex items-center 2xl:gap-[0.556vw] gap-2">
|
||||
{/* App Icon/Image - 32x32px */}
|
||||
<div className="flex justify-center items-center flex-shrink-0 2xl:w-[2.222vw] 2xl:h-[2.222vw] w-8 h-8 2xl:p-[0.556vw] p-2 bg-[#F0F0F0] 2xl:rounded-[0.833vw] rounded-xl">
|
||||
<div className="w-full h-full bg-gradient-to-br from-[#7B60F3] to-[#5B40D3] rounded-sm flex items-center justify-center text-white font-semibold 2xl:text-[0.833vw] text-xs">
|
||||
{session.app?.title?.charAt(0).toUpperCase() || "A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Column */}
|
||||
<div className="flex flex-col justify-center 2xl:gap-[0.139vw] gap-0.5">
|
||||
<p className="caption-xs font-medium text-[#7D7D7D] leading-[110%]">
|
||||
Проект
|
||||
</p>
|
||||
<p className="text-s font-normal text-[#141414] leading-[115%]">
|
||||
{session.app?.title || "Приложение"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section: Timer */}
|
||||
<div className="flex justify-center items-center 2xl:gap-[0.556vw] gap-2 2xl:px-[0.833vw] 2xl:py-[0.556vw] px-3 py-2 bg-[#F3F3F3] 2xl:rounded-[0.833vw] rounded-xl 2xl:w-[3.889vw] w-14 2xl:h-[2.222vw] h-8">
|
||||
<span className="caption-s font-medium text-[#141414] tabular-nums leading-[115%]">
|
||||
{timeRemaining}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Indicator - Always visible, triggers show on hover */}
|
||||
<div
|
||||
className="2xl:w-[4.444vw] w-16 2xl:h-[0.139vw] h-0.5 bg-white/55 2xl:rounded-[1.667vw] rounded-3xl cursor-pointer transition-all duration-200 hover:bg-white/80"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionInfoFloat;
|
||||
@@ -31,6 +31,7 @@ import { useMe } from "../hooks/useAuth";
|
||||
import QuitSessionModal from "../components/modals/QuitSessionModal";
|
||||
import useModalStore from "../store/modalStore";
|
||||
import toast from "react-hot-toast";
|
||||
import SessionInfoFloat from "../components/ui/SessionInfoFloat";
|
||||
|
||||
function SessionPage() {
|
||||
const { setPopup, popupType } = usePopupStore();
|
||||
@@ -192,6 +193,13 @@ function SessionPage() {
|
||||
"overflow-hidden relative order-3 w-screen bg-black h-dvh touch-none max-2xl:flex max-2xl:portrait:items-center"
|
||||
)}
|
||||
>
|
||||
{/* Session Info Float Menu */}
|
||||
{session.status === "started" && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-[160]">
|
||||
<SessionInfoFloat session={session} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pixel Streaming - показывается только когда сессия активна */}
|
||||
{session.status === "started" &&
|
||||
session.mode === "stream" &&
|
||||
|
||||
@@ -36,6 +36,7 @@ function TestPage() {
|
||||
json: {
|
||||
// appId: "60b0a46a-15f6-40db-8f1e-a288c78f8631",
|
||||
appId: "1e762115-8bab-4d41-9bec-55c4a4ad3fbe",
|
||||
// appId: "1b0416fc-fb32-44d9-9c92-20c3e9fe069f",
|
||||
mode: "stream",
|
||||
tier: "demo",
|
||||
},
|
||||
|
||||
@@ -92,7 +92,12 @@ Session Server автоматически управляет игровыми с
|
||||
### Как это работает
|
||||
|
||||
1. **Проверка сессий**: Каждую секунду (или согласно `SESSION_CHECK_INTERVAL_MS`) сервер запрашивает у основного API список сессий для этого сервера
|
||||
2. **Запуск приложений**: Для сессий со статусом `starting`:
|
||||
2. **Проверка времени окончания**: Для активных сессий (`started` или `starting`):
|
||||
- ⏰ Проверяется время `endAt` - если оно наступило, сессия автоматически переводится в статус `ending`
|
||||
- **Timezone**: Все время хранится в UTC, сравнение корректно работает независимо от часового пояса сервера
|
||||
- По умолчанию сессии создаются с временем окончания +30 минут от момента создания
|
||||
- Автоматическое завершение гарантирует, что сессии не будут работать бесконечно
|
||||
3. **Запуск приложений**: Для сессий со статусом `starting`:
|
||||
- ⏰ Проверяется время `startAt` - приложение запускается только если это время уже наступило
|
||||
- **Timezone**: Все время хранится в UTC, сравнение корректно работает независимо от часового пояса сервера
|
||||
- Запланированные сессии (с будущим `startAt`) логируются с информацией о времени до запуска
|
||||
@@ -104,13 +109,13 @@ Session Server автоматически управляет игровыми с
|
||||
- Запускается соответствующее приложение
|
||||
- Отслеживается PID процесса
|
||||
- Статус сессии обновляется на `started` на главном сервере
|
||||
3. **Остановка приложений**: Для сессий со статусом `ending`:
|
||||
4. **Остановка приложений**: Для сессий со статусом `ending`:
|
||||
- Используется `taskkill /pid {PID} /T /F` для завершения всего дерева процессов
|
||||
- `/T` - завершает указанный процесс и ВСЕ дочерние процессы
|
||||
- `/F` - принудительное завершение
|
||||
- Решает проблему с UE5 и другими приложениями, создающими дочерние процессы
|
||||
- Статус сессии обновляется на `ended` на главном сервере
|
||||
4. **Автоматическая очистка**: Процессы для неактивных сессий автоматически останавливаются
|
||||
5. **Автоматическая очистка**: Процессы для неактивных сессий автоматически останавливаются
|
||||
|
||||
### API endpoints для управления сессиями
|
||||
|
||||
@@ -182,6 +187,12 @@ C:\apps\
|
||||
[2025-10-06T10:02:01.100Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "started"
|
||||
```
|
||||
|
||||
Автоматическое завершение по истечении времени:
|
||||
```
|
||||
[2025-10-06T10:32:00.000Z] ⏰ Время сессии 123e4567-e89b-12d3-a456-426614174000 (minecraft) истекло, завершение...
|
||||
[2025-10-06T10:32:00.100Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "ending"
|
||||
```
|
||||
|
||||
Остановка сессии:
|
||||
```
|
||||
[2025-10-06T10:32:00.000Z] 🛑 Остановка приложения для сессии 123e4567-e89b-12d3-a456-426614174000 (PID: 12345)
|
||||
|
||||
Binary file not shown.
+134
-25
@@ -584,11 +584,11 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
// Для stream-режима также останавливаем Cirrus если он ещё работает
|
||||
if (mode === "stream") {
|
||||
const cirrus = activeCirrusProcesses.get(sessionId);
|
||||
if (cirrus) {
|
||||
if (cirrus && cirrus.pid) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Остановка Cirrus для завершённой сессии ${sessionId}`
|
||||
);
|
||||
cirrus.kill("SIGTERM");
|
||||
killProcessTree(cirrus.pid);
|
||||
activeCirrusProcesses.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -614,8 +614,8 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
// Для stream-режима также останавливаем Cirrus
|
||||
if (mode === "stream") {
|
||||
const cirrus = activeCirrusProcesses.get(sessionId);
|
||||
if (cirrus) {
|
||||
cirrus.kill("SIGTERM");
|
||||
if (cirrus && cirrus.pid) {
|
||||
killProcessTree(cirrus.pid);
|
||||
activeCirrusProcesses.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -666,8 +666,8 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
// Очистить Cirrus процесс если он был запущен (только для stream-режима)
|
||||
if (mode === "stream") {
|
||||
const cirrus = activeCirrusProcesses.get(sessionId);
|
||||
if (cirrus) {
|
||||
cirrus.kill("SIGTERM");
|
||||
if (cirrus && cirrus.pid) {
|
||||
killProcessTree(cirrus.pid);
|
||||
activeCirrusProcesses.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -689,17 +689,67 @@ function killProcessTree(pid: number): void {
|
||||
// /T - завершить указанный процесс и все дочерние процессы
|
||||
execSync(`taskkill /pid ${pid} /T /F`, {
|
||||
stdio: "ignore",
|
||||
timeout: 10000,
|
||||
timeout: 30000, // Увеличен таймаут до 30 секунд
|
||||
windowsHide: true, // Скрыть окно консоли на Windows
|
||||
killSignal: "SIGKILL", // Принудительное завершение при таймауте
|
||||
});
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Дерево процессов для PID ${pid} успешно завершено`
|
||||
);
|
||||
} catch (error) {
|
||||
// Проверяем, действительно ли процесс всё ещё существует
|
||||
// Если процесс не существует, это не ошибка
|
||||
const processExists = checkProcessExists(pid);
|
||||
if (!processExists) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Процесс PID ${pid} уже завершён`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ⚠️ Ошибка при завершении дерева процессов PID ${pid}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
|
||||
// Попытка принудительного завершения через PowerShell как запасной вариант
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🔄 Попытка принудительного завершения через PowerShell для PID ${pid}`
|
||||
);
|
||||
execSync(
|
||||
`powershell -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`,
|
||||
{
|
||||
stdio: "ignore",
|
||||
timeout: 15000,
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Процесс PID ${pid} завершён через PowerShell`
|
||||
);
|
||||
} catch (psError) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Не удалось завершить процесс PID ${pid} даже через PowerShell:`,
|
||||
psError instanceof Error ? psError.message : psError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, существует ли процесс с указанным PID
|
||||
*/
|
||||
function checkProcessExists(pid: number): boolean {
|
||||
try {
|
||||
execSync(`tasklist /FI "PID eq ${pid}" | find "${pid}"`, {
|
||||
stdio: "ignore",
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,35 +798,54 @@ async function stopApplication(session: SessionData): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Создаём массив промисов для параллельной остановки процессов
|
||||
const killPromises: Promise<void>[] = [];
|
||||
|
||||
// Останавливаем UE приложение
|
||||
const pidToKill = appPid || appProcess?.pid;
|
||||
if (pidToKill) {
|
||||
// Убиваем весь процесс и все его дочерние процессы
|
||||
killProcessTree(pidToKill);
|
||||
} else if (appProcess) {
|
||||
// Если по какой-то причине нет PID, пробуем стандартный способ
|
||||
appProcess.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
if (!appProcess.killed) {
|
||||
appProcess.kill("SIGKILL");
|
||||
}
|
||||
killPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
// Запускаем killProcessTree в отдельном потоке через setTimeout
|
||||
// чтобы не блокировать основной поток
|
||||
setTimeout(() => {
|
||||
killProcessTree(pidToKill);
|
||||
resolve();
|
||||
}, 0);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[${new Date().toISOString()}] ⚠️ Не удалось получить PID приложения для сессии ${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Останавливаем Cirrus процесс (только для stream-режима)
|
||||
if (mode === "stream") {
|
||||
const cirrusPidToKill = cirrusPid || cirrusProcess?.pid;
|
||||
if (cirrusPidToKill) {
|
||||
killProcessTree(cirrusPidToKill);
|
||||
} else if (cirrusProcess) {
|
||||
cirrusProcess.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
if (!cirrusProcess.killed) {
|
||||
cirrusProcess.kill("SIGKILL");
|
||||
}
|
||||
killPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
killProcessTree(cirrusPidToKill);
|
||||
resolve();
|
||||
}, 0);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[${new Date().toISOString()}] ⚠️ Не удалось получить PID Cirrus для сессии ${sessionId}`
|
||||
);
|
||||
}
|
||||
activeCirrusProcesses.delete(sessionId);
|
||||
}
|
||||
|
||||
// Ждём завершения всех операций остановки с таймаутом
|
||||
await Promise.race([
|
||||
Promise.all(killPromises),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 35000)), // Таймаут 35 секунд
|
||||
]);
|
||||
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended"
|
||||
@@ -860,9 +929,40 @@ async function checkSessions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверить сессии, у которых истекло время endAt
|
||||
// Автоматически переводим их в статус "ending"
|
||||
const expiredSessions = sessions.filter((s) => {
|
||||
// Проверяем только активные сессии (started или starting)
|
||||
// Для starting проверяем, что время startAt уже наступило
|
||||
if (s.status === "starting") {
|
||||
const startAt = new Date(s.startAt);
|
||||
if (startAt > now) return false; // Ещё не началась
|
||||
} else if (s.status !== "started") {
|
||||
return false; // Не активная сессия
|
||||
}
|
||||
|
||||
if (!s.endAt) return false;
|
||||
|
||||
const endAt = new Date(s.endAt);
|
||||
return endAt <= now;
|
||||
});
|
||||
|
||||
// Переводим истекшие сессии в статус "ending"
|
||||
for (const session of expiredSessions) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⏰ Время сессии ${session.id} (${
|
||||
session.app.name
|
||||
}) истекло, завершение...`
|
||||
);
|
||||
await updateSessionStatus(session.id, "ending");
|
||||
}
|
||||
|
||||
// Обработать сессии со статусом "ending" ПЕРЕД запуском новых сессий
|
||||
// Это гарантирует, что процессы будут остановлены до запуска новых
|
||||
const endingSessions = sessions.filter((s) => s.status === "ending");
|
||||
// Включаем как сессии со статусом "ending", так и только что истекшие
|
||||
const endingSessions = sessions.filter(
|
||||
(s) => s.status === "ending" || expiredSessions.some((es) => es.id === s.id)
|
||||
);
|
||||
for (const session of endingSessions) {
|
||||
await stopApplication(session);
|
||||
}
|
||||
@@ -892,8 +992,17 @@ async function checkSessions(): Promise<void> {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Найден процесс для неактивной сессии ${sessionId}, остановка`
|
||||
);
|
||||
process.kill("SIGTERM");
|
||||
if (process.pid) {
|
||||
killProcessTree(process.pid);
|
||||
}
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Также останавливаем Cirrus если есть
|
||||
const cirrusProcess = activeCirrusProcesses.get(sessionId);
|
||||
if (cirrusProcess && cirrusProcess.pid) {
|
||||
killProcessTree(cirrusProcess.pid);
|
||||
activeCirrusProcesses.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user