upd
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "client",
|
||||
"dependencies": {
|
||||
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -36,6 +37,10 @@
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@epicgames-ps/lib-pixelstreamingcommon-ue5.7": ["@epicgames-ps/lib-pixelstreamingcommon-ue5.7@0.1.0", "", { "dependencies": { "@protobuf-ts/runtime": "^2.9.4", "@types/ws": "^8.5.14", "ws": "^8.18.0" } }, "sha512-8+6l0G6Ei6e8U5C9gxFD2DTJazkfR7fEXVfy19C4kOp1tn1/7l1wet6Y5UWvmj82h62i9xRzyaCOF0BxN1ZeGg=="],
|
||||
|
||||
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": ["@epicgames-ps/lib-pixelstreamingfrontend-ue5.7@0.1.1", "", { "dependencies": { "@epicgames-ps/lib-pixelstreamingcommon-ue5.7": "^0.1.0", "sdp": "^3.2.0" } }, "sha512-iGSb5eZRipjyc0A0513zNtgYX/+5U0+OoSTXh/dxWNhG6atnhmAvlrZFc/mx6cmlHbdhcpyN2o6TL8xnej2Kxg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
|
||||
@@ -132,6 +137,8 @@
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@protobuf-ts/runtime": ["@protobuf-ts/runtime@2.11.1", "", {}, "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.35", "", {}, "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.3", "", { "os": "android", "cpu": "arm" }, "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw=="],
|
||||
@@ -218,6 +225,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.45.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/type-utils": "8.45.0", "@typescript-eslint/utils": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.45.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ=="],
|
||||
@@ -538,6 +547,8 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
@@ -604,6 +615,8 @@
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Config,
|
||||
PixelStreaming,
|
||||
} from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7";
|
||||
import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7";
|
||||
|
||||
export interface PixelStreamingWrapperProps {
|
||||
initialSettings?: Partial<AllSettings>;
|
||||
}
|
||||
|
||||
export const PixelStreamingWrapper = ({
|
||||
initialSettings,
|
||||
}: PixelStreamingWrapperProps) => {
|
||||
// A reference to parent div element that the Pixel Streaming library attaches into:
|
||||
const videoParent = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pixel streaming library instance is stored into this state variable after initialization:
|
||||
const [pixelStreaming, setPixelStreaming] = useState<PixelStreaming>();
|
||||
|
||||
// A boolean state variable that determines if the Click to play overlay is shown:
|
||||
const [clickToPlayVisible, setClickToPlayVisible] = useState(false);
|
||||
|
||||
// Run on component mount:
|
||||
useEffect(() => {
|
||||
if (videoParent.current) {
|
||||
// Attach Pixel Streaming library to videoParent element:
|
||||
const config = new Config({ initialSettings });
|
||||
const streaming = new PixelStreaming(config, {
|
||||
videoElementParent: videoParent.current,
|
||||
});
|
||||
|
||||
// register a playStreamRejected handler to show Click to play overlay if needed:
|
||||
streaming.addEventListener("playStreamRejected", () => {
|
||||
setClickToPlayVisible(true);
|
||||
});
|
||||
|
||||
// Save the library instance into component state so that it can be accessed later:
|
||||
setPixelStreaming(streaming);
|
||||
|
||||
// Clean up on component unmount:
|
||||
return () => {
|
||||
try {
|
||||
streaming.disconnect();
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
ref={videoParent}
|
||||
/>
|
||||
{clickToPlayVisible && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
pixelStreaming?.play();
|
||||
setClickToPlayVisible(false);
|
||||
}}
|
||||
>
|
||||
<div>Click to play</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+6
-5
@@ -5,6 +5,7 @@ import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import SessionPage from "./pages/SessionPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
import TestPage from "./pages/TestPage";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
@@ -37,13 +38,13 @@ const router = createBrowserRouter([
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
element: <TestPage />,
|
||||
},
|
||||
{
|
||||
path: "/sessions/:id",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<SessionPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
element: <SessionPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,9 +1,411 @@
|
||||
import { useParams } from "react-router";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import LoaderIcon from "../components/icons/LoaderIcon";
|
||||
import CheckIcon from "../components/icons/CheckIcon";
|
||||
import ClockIcon from "../components/icons/ClockIcon";
|
||||
import WarningIcon from "../components/icons/WarningIcon";
|
||||
import StartIcon from "../components/icons/StartIcon";
|
||||
import Button from "../components/ui/Button";
|
||||
import clsx from "clsx";
|
||||
import { useEffect } from "react";
|
||||
import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
appId: string;
|
||||
userId: string | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
tier: "demo" | "prod" | null;
|
||||
serverId: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
app?: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
};
|
||||
server?: {
|
||||
id: string;
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
tier: "demo" | "prod" | null;
|
||||
location: "ru1" | "uae1" | null;
|
||||
} | null;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function SessionPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <div>SessionPage {id}</div>;
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["session", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`sessions/${id}`).json<{
|
||||
session: Session;
|
||||
}>();
|
||||
return response;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
// Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска
|
||||
const data = query.state.data;
|
||||
if (
|
||||
data?.session.status === "starting" ||
|
||||
data?.session.status === "ending"
|
||||
) {
|
||||
return 2000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const session = sessionData?.session;
|
||||
|
||||
// Перенаправление на тестовую страницу при завершении сессии
|
||||
useEffect(() => {
|
||||
if (session?.status === "ended") {
|
||||
const timer = setTimeout(() => {
|
||||
navigate("/test");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [session?.status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<div className="size-12 text-[#7B60F3] animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<p className="text-gray-600 text-m">
|
||||
Загрузка информации о сессии...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-2xl bg-white rounded-lg shadow-md">
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="text-red-500 size-6">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-2 text-red-900 title-l">Сессия не найдена</h1>
|
||||
<p className="mb-6 text-gray-600 text-m">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось загрузить информацию о сессии"}
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8 min-h-screen bg-gray-50">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Header с названием приложения */}
|
||||
<div className="flex gap-4 justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900 title-l">
|
||||
{session.app?.title || "Приложение"}
|
||||
</h1>
|
||||
<p className="text-gray-500 text-s">
|
||||
ID сессии: {session.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
{/* Pixel Streaming Player - показывается когда сессия запущена */}
|
||||
{session.status === "started" && (
|
||||
<div className="mb-6 aspect-video">
|
||||
<PixelStreamingWrapper
|
||||
initialSettings={{
|
||||
ss: "ws://127.0.0.1:8080",
|
||||
AutoPlayVideo: true,
|
||||
AutoConnect: true,
|
||||
StartVideoMuted: true,
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
}}
|
||||
// onVideoInitialized={() => setIsVideoInitialized(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Основная карточка с информацией */}
|
||||
<div className="grid gap-6 mb-6 md:grid-cols-2">
|
||||
{/* Информация о сессии */}
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<StartIcon />
|
||||
</div>
|
||||
Информация о сессии
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Режим" value={getModeLabel(session.mode)} />
|
||||
{session.tier && (
|
||||
<InfoRow label="Уровень" value={getTierLabel(session.tier)} />
|
||||
)}
|
||||
<InfoRow label="Начало" value={formatDateTime(session.startAt)} />
|
||||
{session.endAt && (
|
||||
<InfoRow
|
||||
label="Завершение"
|
||||
value={formatDateTime(session.endAt)}
|
||||
/>
|
||||
)}
|
||||
{session.appPid && (
|
||||
<InfoRow label="PID приложения" value={session.appPid} />
|
||||
)}
|
||||
{session.cirrusPid && (
|
||||
<InfoRow label="PID Cirrus" value={session.cirrusPid} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о сервере */}
|
||||
{session.server && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Hostname" value={session.server.hostname} />
|
||||
<InfoRow label="IP адрес" value={session.server.localIp} />
|
||||
<InfoRow
|
||||
label="Тип"
|
||||
value={
|
||||
session.server.type === "stream" ? "Стрим" : "Локальный"
|
||||
}
|
||||
/>
|
||||
{session.server.location && (
|
||||
<InfoRow
|
||||
label="Локация"
|
||||
value={getLocationLabel(session.server.location)}
|
||||
/>
|
||||
)}
|
||||
{session.server.tier && (
|
||||
<InfoRow
|
||||
label="Уровень"
|
||||
value={getTierLabel(session.server.tier)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Если сервер еще не назначен */}
|
||||
{!session.server && session.status === "starting" && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="text-yellow-500 animate-spin size-5">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<p className="text-gray-600 text-s">
|
||||
Подбирается подходящий сервер...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о приложении */}
|
||||
{session.app && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm md:col-span-2">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<ClockIcon />
|
||||
</div>
|
||||
Приложение
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<InfoRow label="Название" value={session.app.title} />
|
||||
<InfoRow label="Имя системное" value={session.app.name} />
|
||||
{session.app.gpuLimitMb && (
|
||||
<InfoRow
|
||||
label="Лимит GPU"
|
||||
value={`${session.app.gpuLimitMb} МБ`}
|
||||
/>
|
||||
)}
|
||||
{session.app.psVersion && (
|
||||
<InfoRow label="Версия PS" value={session.app.psVersion} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статусное сообщение */}
|
||||
<StatusMessage status={session.status} />
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button variant="secondary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
{(session.status === "starting" || session.status === "ending") && (
|
||||
<Button variant="primary" onClick={() => refetch()}>
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компоненты помощники
|
||||
function StatusBadge({ status }: { status: Session["status"] }) {
|
||||
const config = {
|
||||
starting: {
|
||||
label: "Запускается",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
started: {
|
||||
label: "Запущена",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
animate: false,
|
||||
},
|
||||
ending: {
|
||||
label: "Завершается",
|
||||
color: "bg-orange-100 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
ended: {
|
||||
label: "Завершена",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
animate: false,
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = config[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-2 items-center px-4 py-2 rounded-xl button-m font-medium",
|
||||
statusConfig.color
|
||||
)}
|
||||
>
|
||||
<div className={clsx("size-4", statusConfig.animate && "animate-spin")}>
|
||||
{statusConfig.icon}
|
||||
</div>
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-gray-500 text-s">{label}:</span>
|
||||
<span className="font-medium text-right text-gray-900 text-s">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMessage({ status }: { status: Session["status"] }) {
|
||||
const messages = {
|
||||
starting: {
|
||||
text: "Сессия запускается. Пожалуйста, подождите...",
|
||||
color: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
started: {
|
||||
text: "Сессия успешно запущена и готова к работе!",
|
||||
color: "bg-green-50 border-green-200 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
},
|
||||
ending: {
|
||||
text: "Сессия завершается...",
|
||||
color: "bg-orange-50 border-orange-200 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
ended: {
|
||||
text: "Сессия завершена. Через 5 секунд вы будете перенаправлены на главную страницу.",
|
||||
color: "bg-gray-50 border-gray-200 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
const message = messages[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 items-start p-4 mb-6 rounded-xl border",
|
||||
message.color
|
||||
)}
|
||||
>
|
||||
<div className="size-5 flex-shrink-0 mt-0.5">{message.icon}</div>
|
||||
<p className="text-m">{message.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Утилиты форматирования
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getModeLabel(mode: "stream" | "local"): string {
|
||||
return mode === "stream" ? "Стриминг" : "Локальный";
|
||||
}
|
||||
|
||||
function getTierLabel(tier: "demo" | "prod"): string {
|
||||
return tier === "demo" ? "Демо" : "Продакшн";
|
||||
}
|
||||
|
||||
function getLocationLabel(location: "ru1" | "uae1"): string {
|
||||
const labels = {
|
||||
ru1: "Россия (ru1)",
|
||||
uae1: "ОАЭ (uae1)",
|
||||
};
|
||||
return labels[location] || location;
|
||||
}
|
||||
|
||||
export default SessionPage;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
appId: string;
|
||||
userId: string | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
tier: "demo" | "prod";
|
||||
serverId: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
}
|
||||
|
||||
function TestPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStartSession = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api
|
||||
.post("sessions", {
|
||||
json: {
|
||||
appId: "2914d736-b928-461c-b58f-e5d35d8b605d",
|
||||
mode: "stream",
|
||||
tier: "demo",
|
||||
},
|
||||
})
|
||||
.json<{ session: Session }>();
|
||||
|
||||
// Перенаправляем на страницу сессии
|
||||
navigate(`/sessions/${response.session.id}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to start session:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Не удалось запустить приложение"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center p-4 min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-md bg-white rounded-lg shadow-md">
|
||||
<h1 className="mb-4 text-2xl font-bold text-gray-900">
|
||||
Тестовая страница
|
||||
</h1>
|
||||
<p className="mb-6 text-gray-600">
|
||||
Запустите демо-приложение без авторизации
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleStartSession}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-3 w-full font-medium text-white bg-blue-600 rounded-lg transition-colors hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Запуск..." : "Запустить приложение"}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 mt-4 text-sm text-red-700 bg-red-100 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestPage;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { authMiddleware } from "../middlewares/auth";
|
||||
import { optionalAuthMiddleware } from "../middlewares/optionalAuth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import db from "../db";
|
||||
import { apps } from "../db/schema/apps";
|
||||
import { serverSessionService } from "../services/serverSession";
|
||||
import { serverService } from "../services/server";
|
||||
|
||||
export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
// PATCH /sessions/:id/status - обновить статус сессии (публичный endpoint для сессионного сервера)
|
||||
@@ -78,45 +80,17 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
}),
|
||||
}
|
||||
)
|
||||
// Все роуты требуют авторизации
|
||||
.use(authMiddleware)
|
||||
// GET /sessions - получить список сессий пользователя
|
||||
.get("/", async ({ currentUser, query }) => {
|
||||
const { status, mode } = query as {
|
||||
status?: "starting" | "started" | "ending" | "ended";
|
||||
mode?: "stream" | "local";
|
||||
};
|
||||
|
||||
const sessions = await serverSessionService.findByUserId(currentUser.id, {
|
||||
status,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { sessions };
|
||||
})
|
||||
// GET /sessions/:id - получить информацию о конкретной сессии
|
||||
.get("/:id", async ({ params, currentUser, status }) => {
|
||||
const { id } = params;
|
||||
|
||||
const session = await serverSessionService.findByIdForUser(
|
||||
id,
|
||||
currentUser.id
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return status(404, "Session not found");
|
||||
}
|
||||
|
||||
return { session };
|
||||
})
|
||||
// POST /sessions - создать новую сессию
|
||||
// Endpoints с optional auth (доступны для неавторизованных пользователей)
|
||||
.use(optionalAuthMiddleware)
|
||||
// POST /sessions - создать новую сессию (с optional auth для demo серверов)
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, currentUser, status }) => {
|
||||
const { appId, mode, serverId } = body as {
|
||||
const { appId, mode, serverId, tier } = body as {
|
||||
appId: string;
|
||||
mode: "stream" | "local";
|
||||
serverId?: string;
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Проверить, что приложение существует
|
||||
@@ -128,6 +102,53 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
return status(404, "App not found");
|
||||
}
|
||||
|
||||
// Если пользователь не авторизован
|
||||
if (!currentUser) {
|
||||
// Проверяем, что режим - stream (только stream поддерживает demo)
|
||||
if (mode !== "stream") {
|
||||
return status(401, "Authorization required for local sessions");
|
||||
}
|
||||
|
||||
// Неавторизованные пользователи могут использовать только demo-серверы
|
||||
if (tier && tier !== "demo") {
|
||||
return status(
|
||||
403,
|
||||
"Unauthorized users can only use demo tier servers"
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем, что есть доступные demo-серверы
|
||||
const demoServers = await serverService.findAvailableStreamServers(
|
||||
"demo"
|
||||
);
|
||||
|
||||
if (demoServers.length === 0) {
|
||||
return status(
|
||||
503,
|
||||
"No available demo servers. Please login to use production servers."
|
||||
);
|
||||
}
|
||||
|
||||
// Создаем сессию без userId (для неавторизованных пользователей)
|
||||
try {
|
||||
const newSession = await serverSessionService.create({
|
||||
appId,
|
||||
// userId не передаем - будет undefined для неавторизованных пользователей
|
||||
mode,
|
||||
serverId,
|
||||
tier: "demo", // Всегда demo для неавторизованных
|
||||
});
|
||||
|
||||
return { session: newSession };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return status(503, error.message);
|
||||
}
|
||||
return status(500, "Failed to create session");
|
||||
}
|
||||
}
|
||||
|
||||
// Если пользователь авторизован - используем стандартную логику
|
||||
// Проверить, что пользователь не имеет активных сессий этого приложения
|
||||
const hasActive = await serverSessionService.hasActiveSession(
|
||||
currentUser.id,
|
||||
@@ -138,6 +159,16 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
return status(409, "User already has an active session for this app");
|
||||
}
|
||||
|
||||
// Для режима stream - проверяем наличие серверов нужного tier
|
||||
if (mode === "stream" && tier) {
|
||||
const availableServers = await serverService.findAvailableStreamServers(
|
||||
tier
|
||||
);
|
||||
if (availableServers.length === 0) {
|
||||
return status(503, `No available ${tier} servers`);
|
||||
}
|
||||
}
|
||||
|
||||
// Создать сессию
|
||||
try {
|
||||
const newSession = await serverSessionService.create({
|
||||
@@ -145,6 +176,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
userId: currentUser.id,
|
||||
mode,
|
||||
serverId,
|
||||
tier,
|
||||
});
|
||||
|
||||
return { session: newSession };
|
||||
@@ -160,9 +192,58 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
appId: t.String({ format: "uuid" }),
|
||||
mode: t.Union([t.Literal("stream"), t.Literal("local")]),
|
||||
serverId: t.Optional(t.String({ format: "uuid" })),
|
||||
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// GET /sessions/:id - получить информацию о конкретной сессии (optional auth)
|
||||
.get("/:id", async ({ params, currentUser, status }) => {
|
||||
const { id } = params;
|
||||
|
||||
// Для авторизованных пользователей - проверяем ownership
|
||||
if (currentUser) {
|
||||
const session = await serverSessionService.findByIdForUser(
|
||||
id,
|
||||
currentUser.id
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return status(404, "Session not found");
|
||||
}
|
||||
|
||||
return { session };
|
||||
}
|
||||
|
||||
// Для неавторизованных - просто находим сессию по ID
|
||||
const session = await serverSessionService.findById(id);
|
||||
|
||||
if (!session) {
|
||||
return status(404, "Session not found");
|
||||
}
|
||||
|
||||
// Проверяем, что это сессия без userId (неавторизованная)
|
||||
if (session.userId) {
|
||||
return status(403, "This session belongs to an authenticated user");
|
||||
}
|
||||
|
||||
return { session };
|
||||
})
|
||||
// Все остальные роуты требуют авторизации
|
||||
.use(authMiddleware)
|
||||
// GET /sessions - получить список сессий пользователя
|
||||
.get("/", async ({ currentUser, query }) => {
|
||||
const { status, mode } = query as {
|
||||
status?: "starting" | "started" | "ending" | "ended";
|
||||
mode?: "stream" | "local";
|
||||
};
|
||||
|
||||
const sessions = await serverSessionService.findByUserId(currentUser.id, {
|
||||
status,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { sessions };
|
||||
})
|
||||
// PATCH /sessions/:id - обновить статус сессии
|
||||
.patch(
|
||||
"/:id",
|
||||
|
||||
@@ -6,3 +6,12 @@ export const roleNameEnum = pgEnum("role_name", [
|
||||
"director",
|
||||
"manager",
|
||||
]);
|
||||
|
||||
// Enum для типов серверов
|
||||
export const serverTypeEnum = pgEnum("server_type", ["stream", "local"]);
|
||||
|
||||
// Enum для местоположения серверов
|
||||
export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]);
|
||||
|
||||
// Enum для tier серверов
|
||||
export const serverTierEnum = pgEnum("server_tier", ["demo", "prod"]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { apps } from "./apps";
|
||||
import { users } from "./users";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverTierEnum } from "./enums";
|
||||
|
||||
// Enums
|
||||
export const sessionModeEnum = pgEnum("session_mode", ["stream", "local"]);
|
||||
@@ -20,16 +21,13 @@ export const serverSessions = pgTable("server_sessions", {
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
startAt: timestamp("start_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
userId: uuid("user_id").references(() => users.id), // Nullable - для неавторизованных пользователей на demo-серверах
|
||||
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
|
||||
tier: serverTierEnum("tier"), // demo, prod (только для stream, nullable)
|
||||
status: sessionStatusEnum("status").notNull(), // starting, started, ending, ended
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
pgEnum,
|
||||
timestamp,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
@@ -10,11 +9,10 @@ import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
import { branches } from "./branches";
|
||||
import { serverLocationEnum, serverTypeEnum, serverTierEnum } from "./enums";
|
||||
|
||||
// Enums
|
||||
export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]);
|
||||
export const serverTypeEnum = pgEnum("server_type", ["stream", "local"]);
|
||||
export const serverTierEnum = pgEnum("server_tier", ["demo", "prod"]);
|
||||
// Re-export enums for backward compatibility
|
||||
export { serverLocationEnum, serverTypeEnum, serverTierEnum };
|
||||
|
||||
export const servers = pgTable("servers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { AuthSession, authSessions } from "../db/schema/authSessions";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import db from "../db";
|
||||
import { jwtVerify } from "jose";
|
||||
import { userService } from "../services/auth/user";
|
||||
import { protectedRoutes } from "../db/schema";
|
||||
import { RoleName } from "../services/auth";
|
||||
|
||||
// JWT секрет (должен совпадать с session.service.ts)
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
|
||||
/**
|
||||
* Optional auth middleware - проверяет авторизацию если токен предоставлен,
|
||||
* но не требует его обязательно
|
||||
*/
|
||||
export const optionalAuthMiddleware = new Elysia().derive(
|
||||
{ as: "scoped" },
|
||||
async ({ request }) => {
|
||||
const { headers } = request;
|
||||
const authHeader = headers.get("Authorization");
|
||||
|
||||
// Если нет заголовка авторизации, продолжаем без пользователя
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = authHeader.split(" ")[1];
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Верифицировать JWT (проверка подписи и срока действия)
|
||||
let sessionId: string;
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<{ id: string }>(
|
||||
accessToken,
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
sessionId = payload.id;
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Получить сессию из БД и проверить её валидность
|
||||
let authSession: AuthSession;
|
||||
|
||||
try {
|
||||
authSession = (
|
||||
await db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(authSessions.id, sessionId),
|
||||
isNull(authSessions.revokedAt) // Сессия не отозвана
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
if (!authSession) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Database error in optional auth middleware:", err);
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Проверить срок действия сессии
|
||||
if (authSession.expiresAt && authSession.expiresAt < new Date()) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Верифицировать bcrypt hash токена
|
||||
try {
|
||||
const verified = await Bun.password.verify(
|
||||
accessToken,
|
||||
authSession.accessTokenHash
|
||||
);
|
||||
|
||||
if (!verified) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Token verification error:", err);
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Получить пользователя
|
||||
const user = await userService.findById(authSession.userId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Проверить доступ к маршруту на основе ролей (если маршрут защищен)
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// Получить маршрут из БД
|
||||
const route = await db
|
||||
.select({
|
||||
methods: protectedRoutes.methods,
|
||||
roles: protectedRoutes.roles,
|
||||
})
|
||||
.from(protectedRoutes)
|
||||
.where(eq(protectedRoutes.path, path))
|
||||
.limit(1);
|
||||
|
||||
// Если маршрут защищен, проверить права доступа
|
||||
if (route.length > 0) {
|
||||
const allowedMethods = route[0].methods;
|
||||
const allowedRoles = route[0].roles;
|
||||
|
||||
// Проверить, что метод входит в список разрешенных
|
||||
if (allowedMethods.includes(method)) {
|
||||
// Проверить, есть ли роль пользователя среди разрешенных
|
||||
if (!allowedRoles.includes(user.role as RoleName)) {
|
||||
return {
|
||||
authSession: null,
|
||||
currentUser: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Всё ОК - вернуть сессию и санитизированного пользователя (без пароля)
|
||||
return {
|
||||
authSession,
|
||||
currentUser: userService.sanitize(user),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -9,9 +9,10 @@ export type SessionStatus = "starting" | "started" | "ending" | "ended";
|
||||
|
||||
export interface CreateSessionParams {
|
||||
appId: string;
|
||||
userId: string;
|
||||
userId?: string; // Optional для неавторизованных пользователей на demo-серверах
|
||||
mode: SessionMode;
|
||||
serverId?: string;
|
||||
tier?: "demo" | "prod"; // Предпочитаемый tier для stream-сессий
|
||||
}
|
||||
|
||||
export interface UpdateSessionParams {
|
||||
@@ -197,7 +198,12 @@ export const serverSessionService = {
|
||||
/**
|
||||
* Проверить, есть ли у пользователя активная сессия для данного приложения
|
||||
*/
|
||||
async hasActiveSession(userId: string, appId: string) {
|
||||
async hasActiveSession(userId: string | undefined, appId: string) {
|
||||
// Для неавторизованных пользователей не проверяем активные сессии
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = await db.query.serverSessions.findFirst({
|
||||
where: and(
|
||||
eq(serverSessions.userId, userId),
|
||||
@@ -233,7 +239,7 @@ export const serverSessionService = {
|
||||
* Создать новую сессию
|
||||
*/
|
||||
async create(params: CreateSessionParams) {
|
||||
const { appId, userId, mode, serverId } = params;
|
||||
const { appId, userId, mode, serverId, tier } = params;
|
||||
|
||||
// Для local-сессий выбираем сервер сразу
|
||||
// Для stream-сессий сервер будет назначен динамически при запуске
|
||||
@@ -257,6 +263,7 @@ export const serverSessionService = {
|
||||
appId,
|
||||
userId,
|
||||
mode,
|
||||
tier, // Предпочитаемый tier (для stream-сессий)
|
||||
status: "starting",
|
||||
endAt,
|
||||
})
|
||||
@@ -362,12 +369,14 @@ export const serverSessionService = {
|
||||
|
||||
if (session.mode === "stream") {
|
||||
// Для stream-сессий выбираем сервер с максимальной свободной памятью
|
||||
// Ищем среди всех tier (prod и demo)
|
||||
const availableServers = await serverService.findAvailableStreamServers();
|
||||
// Приоритет: tier из сессии > demo для неавторизованных > все серверы для авторизованных
|
||||
const tier = session.tier || (session.userId ? undefined : "demo");
|
||||
const availableServers = await serverService.findAvailableStreamServers(tier);
|
||||
|
||||
if (availableServers.length === 0) {
|
||||
const serverType = tier === "demo" ? "demo " : "";
|
||||
throw new Error(
|
||||
"No available stream servers (check that stream servers are registered)"
|
||||
`No available ${serverType}stream servers (check that stream servers are registered)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+47
-22
@@ -377,13 +377,15 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🚀 Запуск приложения "${app.name}" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})`
|
||||
`[${new Date().toISOString()}] 🚀 Запуск приложения "${
|
||||
app.name
|
||||
}" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})`
|
||||
);
|
||||
|
||||
// Формируем путь к exe файлу приложения
|
||||
// Путь: C:\apps\{appName}\{appName}.exe
|
||||
const appPath = `C:\\apps\\${app.name}\\${app.name}.exe`;
|
||||
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 📂 Путь к приложению: ${appPath}`
|
||||
);
|
||||
@@ -397,12 +399,23 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
|
||||
// Запускаем exe приложение
|
||||
// Используем 'pipe' для stderr чтобы видеть ошибки, но 'ignore' для stdin/stdout
|
||||
const appProcess = spawn(appPath, [], {
|
||||
detached: false,
|
||||
stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe
|
||||
windowsHide: true, // Скрывать окно консоли на Windows
|
||||
cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения
|
||||
});
|
||||
const appProcess = spawn(
|
||||
appPath,
|
||||
[
|
||||
"-PixelStreamingURL=ws://127.0.0.1:8888",
|
||||
"-ForceRes",
|
||||
"-ResX=1920",
|
||||
"-ResY=1080",
|
||||
"-Unattended",
|
||||
"-RenderOffScreen",
|
||||
],
|
||||
{
|
||||
detached: false,
|
||||
stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe
|
||||
windowsHide: true, // Скрывать окно консоли на Windows
|
||||
cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения
|
||||
}
|
||||
);
|
||||
|
||||
const appPid = appProcess.pid;
|
||||
|
||||
@@ -417,7 +430,9 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
if (appProcess.stderr) {
|
||||
appProcess.stderr.on("data", (data) => {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data.toString().trim()}`
|
||||
`[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -425,15 +440,17 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
// Обработка завершения процесса
|
||||
appProcess.on("exit", async (code, signal) => {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${signal ? ` (сигнал: ${signal})` : ""}`
|
||||
`[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${
|
||||
signal ? ` (сигнал: ${signal})` : ""
|
||||
}`
|
||||
);
|
||||
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение завершилось с ошибкой! Код выхода: ${code}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended"
|
||||
@@ -446,7 +463,7 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
error
|
||||
);
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
|
||||
// Обновить статус на "ended" в случае ошибки
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
});
|
||||
@@ -455,7 +472,9 @@ async function startApplication(session: SessionData): Promise<void> {
|
||||
await updateSessionStatus(sessionId, "started", appPid);
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${activeProcesses.size})`
|
||||
`[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${
|
||||
activeProcesses.size
|
||||
})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -518,7 +537,9 @@ async function stopApplication(session: SessionData): Promise<void> {
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${appPid || appProcess?.pid || "неизвестен"})`
|
||||
`[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${
|
||||
appPid || appProcess?.pid || "неизвестен"
|
||||
})`
|
||||
);
|
||||
|
||||
// Используем PID из базы данных если он есть, иначе из процесса
|
||||
@@ -576,31 +597,35 @@ async function checkSessions(): Promise<void> {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt <= now;
|
||||
});
|
||||
|
||||
|
||||
// Логировать запланированные сессии, которые ещё не пришло время запускать
|
||||
// Логируем не чаще раза в 10 секунд для каждой сессии, чтобы не спамить логами
|
||||
const scheduledSessions = allStartingSessions.filter((s) => {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt > now;
|
||||
});
|
||||
|
||||
|
||||
if (scheduledSessions.length > 0) {
|
||||
for (const session of scheduledSessions) {
|
||||
const lastLogTime = lastScheduledLogTime.get(session.id) || 0;
|
||||
const timeSinceLastLog = now.getTime() - lastLogTime;
|
||||
|
||||
|
||||
// Логируем только если прошло больше 10 секунд с последнего лога
|
||||
if (timeSinceLastLog > 10000) {
|
||||
const startAt = new Date(session.startAt);
|
||||
const timeUntilStart = Math.round((startAt.getTime() - now.getTime()) / 1000);
|
||||
const timeUntilStart = Math.round(
|
||||
(startAt.getTime() - now.getTime()) / 1000
|
||||
);
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${session.app.name}) запланирована через ${timeUntilStart} сек`
|
||||
`[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${
|
||||
session.app.name
|
||||
}) запланирована через ${timeUntilStart} сек`
|
||||
);
|
||||
lastScheduledLogTime.set(session.id, now.getTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Очистить карту логирования для сессий, которые больше не запланированы
|
||||
const scheduledSessionIds = new Set(scheduledSessions.map((s) => s.id));
|
||||
for (const sessionId of lastScheduledLogTime.keys()) {
|
||||
@@ -608,7 +633,7 @@ async function checkSessions(): Promise<void> {
|
||||
lastScheduledLogTime.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (const session of startingSessions) {
|
||||
await startApplication(session);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user