This commit is contained in:
2025-10-10 19:23:53 +05:00
parent b5dc953d6b
commit 7bb50a4ee5
13 changed files with 961 additions and 79 deletions
+13
View File
@@ -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=="],
+1
View File
@@ -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
View File
@@ -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 />,
},
]);
+404 -2
View File
@@ -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;
+79
View File
@@ -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;