diff --git a/client/src/components/ui/SessionInfoFloat.tsx b/client/src/components/ui/SessionInfoFloat.tsx new file mode 100644 index 0000000..64fc81a --- /dev/null +++ b/client/src/components/ui/SessionInfoFloat.tsx @@ -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(""); + const [isVisible, setIsVisible] = useState(true); + const [isAnimating, setIsAnimating] = useState(false); + const hideTimeoutRef = useRef(null); + const animationTimeoutRef = useRef(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 ( +
+ {/* Main Container with shadow - animates in/out */} +
+ {/* Left Section: Image + Text */} +
+ {/* App Icon/Image - 32x32px */} +
+
+ {session.app?.title?.charAt(0).toUpperCase() || "A"} +
+
+ + {/* Text Column */} +
+

+ Проект +

+

+ {session.app?.title || "Приложение"} +

+
+
+ + {/* Right Section: Timer */} +
+ + {timeRemaining} + +
+
+ + {/* Bottom Indicator - Always visible, triggers show on hover */} +
+
+ ); +} + +export default SessionInfoFloat; diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx index 10a25a6..b854c7a 100644 --- a/client/src/pages/SessionPage.tsx +++ b/client/src/pages/SessionPage.tsx @@ -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" && ( +
+ +
+ )} + {/* Pixel Streaming - показывается только когда сессия активна */} {session.status === "started" && session.mode === "stream" && diff --git a/client/src/pages/TestPage.tsx b/client/src/pages/TestPage.tsx index 747bfa4..a9857d2 100644 --- a/client/src/pages/TestPage.tsx +++ b/client/src/pages/TestPage.tsx @@ -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", }, diff --git a/session-server/README.md b/session-server/README.md index 32d91f4..5e5c5b5 100644 --- a/session-server/README.md +++ b/session-server/README.md @@ -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) diff --git a/session-server/session-server.rar b/session-server/session-server.rar new file mode 100644 index 0000000..e6ed923 Binary files /dev/null and b/session-server/session-server.rar differ diff --git a/session-server/src/index.ts b/session-server/src/index.ts index b3418d8..f2ea430 100644 --- a/session-server/src/index.ts +++ b/session-server/src/index.ts @@ -584,11 +584,11 @@ async function startApplication(session: SessionData): Promise { // Для 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 { // Для 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 { // Очистить 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 { ); } + // Создаём массив промисов для параллельной остановки процессов + const killPromises: Promise[] = []; + // Останавливаем 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((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((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((resolve) => setTimeout(resolve, 35000)), // Таймаут 35 секунд + ]); + activeProcesses.delete(sessionId); // Обновить статус на "ended" @@ -860,9 +929,40 @@ async function checkSessions(): Promise { } } + // Проверить сессии, у которых истекло время 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 { 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) {