- added task rendering instead of molecule
- fied a lot of errors
- changes experiment page to show generic experiment info
This commit is contained in:
2026-05-25 12:55:46 +03:00
parent 26623dc2c1
commit f3372eb0ad
43 changed files with 5541 additions and 6908 deletions

View File

@@ -0,0 +1,112 @@
name: Build and Deploy React Package
# Controls when the workflow will run. Here, it runs on every push to the 'main' branch.
on:
push:
branches: ["main"]
# Environment variables used across the workflow
env:
# The URL of your Gitea instance (without http:// or https://)
GITEA_INSTANCE_URL: git.deowl.ru
# The full name of your package (e.g., 'myusername/myproject')
PACKAGE_NAME: vkrb/quantum_frontend
# Node.js version to use
NODE_VERSION: "25.9.0"
jobs:
build-and-publish:
# Runs the job on a runner with the 'ubuntu-latest' label.
runs-on: ubuntu-latest
steps:
# 1. Check out your repository code so the workflow can access it.
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# 3. Install pnpm globally
- name: Install pnpm
run: npm install -g pnpm
# 4. Install dependencies with pnpm
- name: Install dependencies
run: pnpm install
# 5. Build the React/Vite application
# This assumes your vite.config.js/ts is configured to output to 'dist'
- name: Build application
run: pnpm run build
# 6. Create a tarball of the build artifacts
- name: Create package tarball
run: |
# Create a directory for the package
mkdir -p package_artifact
# Copy build artifacts (adjust path if your build outputs to a different directory)
cp -r dist package_artifact/
# Optionally copy other important files
# cp package.json package_artifact/
# cp README.md package_artifact/
# Create tarball
tar -czf package.tar.gz -C package_artifact .
# Generate checksum for integrity
sha256sum package.tar.gz > package.tar.gz.sha256
# 7. Log in to Gitea Package Registry
- name: Log in to Gitea Package Registry
run: |
# Create .netrc file for authentication with Gitea
cat > ~/.netrc << EOF
machine ${{ env.GITEA_INSTANCE_URL }}
login ${{ gitea.repository_owner }}
password ${{ secrets.REGISTRY_TOKEN }}
EOF
# 8. Upload the package to Gitea Generic Package Registry
- name: Upload to Gitea Generic Package Registry
run: |
# The format for Gitea generic packages:
# https://{GITEA_INSTANCE_URL}/api/packages/{owner}/generic/{package-name}/{version}/{file-name}
# Generate version from git commit SHA and timestamp
VERSION="${{ gitea.sha }}"
# Upload the tarball
curl --fail-with-body \
--netrc \
--upload-file package.tar.gz \
"https://${{ env.GITEA_INSTANCE_URL }}/api/packages/${{ gitea.repository_owner }}/generic/${{ env.PACKAGE_NAME }}/${VERSION}/package.tar.gz"
# Upload the checksum file
curl --fail-with-body \
--netrc \
--upload-file package.tar.gz.sha256 \
"https://${{ env.GITEA_INSTANCE_URL }}/api/packages/${{ gitea.repository_owner }}/generic/${{ env.PACKAGE_NAME }}/${VERSION}/package.tar.gz.sha256"
# 9. (Optional) Create a latest version for convenience
- name: Update latest version
run: |
# Upload as 'latest' version
curl --fail-with-body \
--netrc \
--upload-file package.tar.gz \
"https://${{ env.GITEA_INSTANCE_URL }}/api/packages/${{ gitea.repository_owner }}/generic/${{ env.PACKAGE_NAME }}/latest/package.tar.gz"
curl --fail-with-body \
--netrc \
--upload-file package.tar.gz.sha256 \
"https://${{ env.GITEA_INSTANCE_URL }}/api/packages/${{ gitea.repository_owner }}/generic/${{ env.PACKAGE_NAME }}/latest/package.tar.gz.sha256"
# 10. Clean up
- name: Clean up
run: rm -f package.tar.gz package.tar.gz.sha256

View File

@@ -10,36 +10,36 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^9.1.1", "@mantine/core": "9.1.1",
"@mantine/hooks": "^9.1.1", "@mantine/hooks": "9.1.1",
"@mantine/notifications": "^9.1.1", "@mantine/notifications": "9.1.1",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "3.34.1",
"axios": "^1.13.2", "axios": "1.13.2",
"dotenv": "^17.4.2", "dotenv": "17.4.2",
"immer": "^11.1.4", "immer": "11.1.4",
"keycloak-js": "^26.2.3", "keycloak-js": "26.2.3",
"miew-react": "^0.11.0", "miew-react": "0.11.0",
"react": "^19.2.5", "react": "19.2.5",
"react-dom": "^19.2.5", "react-dom": "19.2.5",
"react-helmet": "^6.1.0", "react-helmet": "6.1.0",
"react-resizable-panels": "^4.5.4", "react-resizable-panels": "4.5.4",
"react-router": "^7.9.6", "react-router": "7.9.6",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "5.1.4",
"zustand": "^5.0.8" "zustand": "5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "9.33.0",
"@types/node": "^25.6.0", "@types/node": "25.6.0",
"@types/react": "^18.2.0", "@types/react": "18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "18.2.0",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "6.1.11",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "5.0.2",
"eslint": "^9.33.0", "eslint": "9.33.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "0.4.20",
"globals": "^16.3.0", "globals": "16.3.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.1", "typescript-eslint": "8.39.1",
"vite": "^7.1.2" "vite": "7.1.2"
} }
} }

2921
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,49 +0,0 @@
import axios, { AxiosError } from "axios";
import type { ConvertSchema } from "Types/ApiCalls/ConvertBackendCallsTypes";
export async function ConvertMoleculeToStandart(
data: ConvertSchema,
): Promise<string | AxiosError> {
try {
const response = await axios.post(
import.meta.env.VITE_MOLECULAR_BACKEND_URL + "/convert",
{
text: data.inputText,
format: data.inputFormat,
convert_3d: data.make_3d,
add_hydrogen: data.add_h,
optimize_geometry: data.optimize,
},
);
// Response from the FastAPI JSONResponse
return response.data.molfile;
} catch (error) {
//Error handling
if (axios.isAxiosError(error)) {
// Do something with the axios error...
return error;
} else {
throw error;
}
}
}
export async function GetInFormats(): Promise<
{ [key: string]: string } | AxiosError
> {
try {
const response = await axios.get(
import.meta.env.VITE_MOLECULAR_BACKEND_URL + "/informats",
);
return response.data;
} catch (error) {
//Error handling
if (axios.isAxiosError(error)) {
// Do something with the axios error...
return error;
} else {
throw error;
}
}
}

View File

@@ -61,8 +61,6 @@ export const updatePasswordWithRedirect = async (
redirectUri: redirectUri:
successRedirectUrl || successRedirectUrl ||
window.location.origin + window.location.origin +
"/" +
import.meta.env.VITE_BASE_PATH +
"/" + "/" +
routes.SettingsPage.path + routes.SettingsPage.path +
"?password_updated=true", "?password_updated=true",
@@ -85,7 +83,6 @@ export const SendEmailVerification = async () => {
action: "VERIFY_EMAIL", action: "VERIFY_EMAIL",
redirectUri: redirectUri:
window.location.origin + window.location.origin +
import.meta.env.VITE_BASE_PATH +
"/" + "/" +
routes.SettingsPage.path + routes.SettingsPage.path +
"?email_sent=true", "?email_sent=true",

View File

@@ -1,58 +1,59 @@
import React, { useRef, useEffect, useState, useMemo } from "react"; import { LoadingOverlay } from "@mantine/core";
import React, { useRef, useEffect, useState } from "react";
import { useUserPreferencesStore } from "Stores/PreferencesStore"; import { useUserPreferencesStore } from "Stores/PreferencesStore";
import type { TaskData } from "Types/Experiment/Experiment";
interface IframePluginProps { interface IframePluginProps {
pluginUrl: string; index: number;
mode: "list" | "editor"; plugin: string;
taskData: TaskData<any>; mode: "List" | "Editor";
onUpdate?: (data: any) => void; taskData: string;
simProgress: string;
qubits_needed: number;
onUpdate?: (data: string, qubits_needed: number) => void;
} }
export const IframePlugin: React.FC<IframePluginProps> = ({ export const IframePlugin: React.FC<IframePluginProps> = ({
pluginUrl, index,
plugin,
mode, mode,
taskData, taskData,
simProgress,
qubits_needed,
onUpdate, onUpdate,
}) => { }) => {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const theme = useUserPreferencesStore(); const theme = useUserPreferencesStore();
const [isIframeReady, setIsIframeReady] = useState(false); const [isIframeReady, setIsIframeReady] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Memoize the iframe component to prevent recreation when taskData changes
const memoizedIframe = useMemo(() => {
const url = `${pluginUrl}?mode=${mode}`;
return (
<iframe
ref={iframeRef}
src={url}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
style={{
width: "100%",
display: "flex",
flexDirection: "column",
flexGrow: 1,
border: "none",
}}
title="Plugin"
/>
);
}, [pluginUrl, mode]); // Only recreate when pluginUrl or mode changes
// Send data to iframe when taskData or theme changes, or when iframe becomes ready // Send data to iframe when taskData or theme changes, or when iframe becomes ready
useEffect(() => { useEffect(() => {
if (!isIframeReady || !iframeRef.current?.contentWindow) return; if (!isIframeReady || !iframeRef.current?.contentWindow) return;
const message = { const message = {
type: "plugin-data", type: "plugin-data",
data: { data: {
taskData, taskData: taskData,
simProgress: simProgress,
qubits_needed: qubits_needed,
theme: theme.theme, theme: theme.theme,
mode: mode,
}, },
}; };
iframeRef.current.contentWindow.postMessage(message, "*"); iframeRef.current.contentWindow.postMessage(message, "*");
}, [taskData, theme.theme, isIframeReady]); setIsVisible(true);
}, [taskData, theme.theme, isIframeReady, mode, simProgress]);
useEffect(() => {
if (isVisible) {
if (!isIframeReady || !iframeRef.current?.contentWindow) return;
const message = {
type: "update",
};
iframeRef.current.contentWindow.postMessage(message, "*");
}
}, [index]);
// Listen for messages from iframe // Listen for messages from iframe
useEffect(() => { useEffect(() => {
@@ -65,7 +66,8 @@ export const IframePlugin: React.FC<IframePluginProps> = ({
// Handle plugin updates // Handle plugin updates
if (event.data.type === "plugin-update" && onUpdate) { if (event.data.type === "plugin-update" && onUpdate) {
onUpdate(event.data.data); console.log(event.data);
onUpdate(event.data.data, event.data.qubits_needed);
} }
}; };
@@ -73,10 +75,33 @@ export const IframePlugin: React.FC<IframePluginProps> = ({
return () => window.removeEventListener("message", handler); return () => window.removeEventListener("message", handler);
}, [onUpdate]); }, [onUpdate]);
// Reset ready state when URL changes (new plugin or mode) return (
useEffect(() => { <>
setIsIframeReady(false); <LoadingOverlay visible={!isIframeReady} />
}, [pluginUrl, mode]); <div
style={{
return memoizedIframe; visibility: isVisible ? "visible" : "hidden",
flexGrow: "1",
maxHeight: "100%",
display: "flex",
}}
>
<iframe
ref={iframeRef}
srcDoc={plugin}
sandbox="allow-scripts allow-popups allow-forms"
style={{
width: "100%",
display: "flex",
flexDirection: "column",
flexGrow: 1,
border: "none",
pointerEvents: mode == "List" ? "none" : "initial",
}}
name={`Ifame#${index}`}
id={`Ifame#${index}`}
></iframe>
</div>
</>
);
}; };

View File

@@ -0,0 +1,186 @@
import keycloak from "Api/Keycloak/Keycloak";
import axios from "axios";
import type {
CreateExperimentRequest,
ExperimentData,
ExperimentListResponse,
CreateInstanceRequest,
SimpleInstanceData,
UpdateInstanceRequest,
InstanceListResponse,
InstanceData,
StartExperimentRequest,
UpdateExperimentRequest,
CreateExperimentTypeResponse,
ExperimentTypeList,
} from "Types/Experiment/Experiment";
const api = axios.create({
baseURL: `${import.meta.env.VITE_QUANTUM_BACKEND_URL}/experiment`,
headers: { "Content-Type": "application/json" },
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = keycloak.token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ============= EXPERIMENT TYPE ENDPOINTS =============
// 1. Get all experiment types
export const getExperimentTypes = async (): Promise<ExperimentTypeList[]> => {
const response = await api.get<ExperimentTypeList[]>("/types");
return response.data;
};
// 2. Create experiment type (admin only, with file uploads)
export const createExperimentType = async (
name: string,
fileFrontend: File,
fileCompSystem: File,
description?: string,
): Promise<CreateExperimentTypeResponse> => {
const formData = new FormData();
formData.append("name", name);
formData.append("file_frontend", fileFrontend);
formData.append("file_comp_system", fileCompSystem);
if (description) {
formData.append("description", description);
}
const response = await api.post<CreateExperimentTypeResponse>(
"/types",
formData,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
return response.data;
};
// 3. Get frontend HTML file for experiment type
export const getFrontendFile = async (
experiment_type_id: number,
): Promise<string> => {
const response = await api.get<string>("/types/frontend", {
params: { experiment_type_id },
});
return response.data;
};
// ============= EXPERIMENT ENDPOINTS =============
// 5. Create a new experiment
export const createExperiment = async (
data: CreateExperimentRequest,
): Promise<ExperimentData> => {
const response = await api.post<ExperimentData>("", data);
return response.data;
};
// 6. Get single experiment by ID
export const getExperimentById = async (
experiment_id: number,
): Promise<ExperimentData> => {
const response = await api.get<ExperimentData>("", {
params: { experiment_id },
});
return response.data;
};
// 7. Update experiment
export const updateExperiment = async (
data: UpdateExperimentRequest,
): Promise<ExperimentData> => {
const response = await api.put<ExperimentData>("", data);
return response.data;
};
// 8. Get user's experiments (with pagination)
export const getUserExperiments = async ({
page_num = 1,
page_size = 10,
}: {
page_num?: number;
page_size?: number;
}): Promise<ExperimentListResponse> => {
const response = await api.get<ExperimentListResponse>("/user", {
params: { page_num, page_size },
});
return response.data;
};
// 9. Delete experiment
export const deleteExperiment = async (
experiment_id: number,
): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>("", {
params: { experiment_id },
});
return response.data;
};
// ============= INSTANCE ENDPOINTS =============
// 10. Create instance for an experiment
export const createInstance = async (
data: CreateInstanceRequest,
): Promise<SimpleInstanceData> => {
const response = await api.post<SimpleInstanceData>("/instance", data);
return response.data;
};
// 11. Update instance
export const updateInstance = async (
data: UpdateInstanceRequest,
): Promise<SimpleInstanceData> => {
const response = await api.put<SimpleInstanceData>("/instance", data);
return response.data;
};
// 12. Get all instances of an experiment (with pagination)
export const getExperimentInstances = async (
experiment_id: number,
page_num: number = 1,
page_size: number = 6,
): Promise<InstanceListResponse> => {
page_num = Math.max(page_num, 1);
const response = await api.get<InstanceListResponse>("/instance", {
params: { experiment_id, page_num, page_size },
});
return response.data;
};
// 13. Get single instance by ID
export const getInstanceById = async (
instance_id: number,
): Promise<InstanceData> => {
const response = await api.get<InstanceData>("/instance/id", {
params: { instance_id },
});
return response.data;
};
// 14. Delete instance
export const deleteInstance = async (
instance_id: number,
): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>("/instance", {
params: { instance_id },
});
return response.data;
};
// ============= SIMULATION ENDPOINTS =============
// 15. Start experiment (run simulation)
export const startExperiment = async (
data: StartExperimentRequest,
): Promise<200> => {
const response = await api.post<200>("/start", data);
return response.data;
};

View File

@@ -25,25 +25,38 @@ export const GetCurrentUserInfo = async (): Promise<UserData | undefined> => {
} }
}; };
// In Api/QuantumBackend/UserManagement.ts
export const UpdateCurrentUserInfo = async ( export const UpdateCurrentUserInfo = async (
profile_picture_path: string, file: File,
): Promise<UserData | undefined> => { ): Promise<{ profile_picture_path: string } | undefined> => {
const response = await axios.put( try {
`${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user`, const formData = new FormData();
{ profile_picture_path: profile_picture_path }, formData.append("file", file);
const response = await fetch(
`${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user/upload`,
{ {
method: "POST",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${keycloak.token}`, Authorization: `Bearer ${keycloak.token}`,
}, },
withCredentials: true, // Important: allows credentials in CORS body: formData,
}, },
); );
if (response.status === 200) {
return response.data; if (!response.ok) {
throw new Error("Failed to upload profile picture");
}
return await response.json();
} catch (error) {
console.error("Error uploading profile picture:", error);
return undefined;
} }
}; };
// Keep your existing GetCurrentUserInfo function
export const GetUserByEmail = async ( export const GetUserByEmail = async (
email: string, email: string,
): Promise<UserData | undefined> => { ): Promise<UserData | undefined> => {

View File

@@ -35,8 +35,7 @@ function App() {
keycloak.updateToken(60).catch((error) => { keycloak.updateToken(60).catch((error) => {
console.log(error); console.log(error);
keycloak.logout({ keycloak.logout({
redirectUri: redirectUri: window.location.origin,
window.location.origin + "/" + import.meta.env.VITE_BASE_PATH,
}); });
}); });
}; };

View File

@@ -5,9 +5,11 @@
display: flex; display: flex;
padding-left: 5px; padding-left: 5px;
padding-right: 5px; padding-right: 5px;
position: relative;
overflow: "hidden";
} }
.colored:hover { :not(.button-Disabled).colored:hover {
background-color: color; background-color: color;
} }
@@ -38,7 +40,7 @@
--hover_color: color-mix(in srgb, var(--hovercolor) 20%, transparent); --hover_color: color-mix(in srgb, var(--hovercolor) 20%, transparent);
} }
.outline:hover { :not(.button-Disabled).outline:hover {
background: var(--hover_color); background: var(--hover_color);
} }
@@ -50,7 +52,7 @@
--hover_color: var(--hovercolor); --hover_color: var(--hovercolor);
} }
.color:hover { :not(.button-Disabled).color:hover {
background-color: var(--hover_color); background-color: var(--hover_color);
} }
@@ -103,11 +105,10 @@
--hover_color: color-mix(in srgb, var(--color) 20%, transparent); --hover_color: color-mix(in srgb, var(--color) 20%, transparent);
} }
.subtle:hover { :not(.button-Disabled).subtle:hover {
background-color: --hover_color; background-color: --hover_color;
} }
.button-Disabled { .button-Disabled {
background-color: var(--hover_color); cursor: default;
cursor: inherit;
} }

View File

@@ -33,10 +33,8 @@ function CustomButton({
}: CustomButtonProps) { }: CustomButtonProps) {
return ( return (
<UnstyledButton <UnstyledButton
className={ className={`colored ${style} ${color} textAlign-${textAlign} +
`colored ${style} ${color} textAlign-${textAlign} ` + ${disabled ? "button-Disabled" : ""}`}
(disabled ? "button-Disabled" : "")
}
style={ style={
color !== "primary" && color !== "primary" &&
color !== "secondary" && color !== "secondary" &&
@@ -55,6 +53,19 @@ function CustomButton({
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
> >
{disabled && (
<div
style={{
position: "absolute",
left: "-2px",
right: "-2px",
bottom: "-2px",
top: "-2px",
borderRadius: "5px",
backgroundColor: "rgba(10, 10, 10, 0.5)",
}}
></div>
)}
{icon} {icon && <Space w="sm" />} {icon} {icon && <Space w="sm" />}
<Text size={textSize}>{text}</Text> <Text size={textSize}>{text}</Text>
</UnstyledButton> </UnstyledButton>

View File

@@ -4,8 +4,7 @@ import { useLayoutStore } from "Stores/LayoutStore";
import { Link } from "react-router"; import { Link } from "react-router";
import { routes } from "Routes/Routes"; import { routes } from "Routes/Routes";
const logoUrl = const logoUrl = window.location.origin + "/bitmap.png";
window.location.origin + "/" + import.meta.env.VITE_BASE_PATH + "/bitmap.png";
function Header() { function Header() {
const { is_navbar_open, set_navbar_open } = useLayoutStore(); const { is_navbar_open, set_navbar_open } = useLayoutStore();

View File

@@ -18,8 +18,13 @@ import {
IconUsers, IconUsers,
type IconProps, type IconProps,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router"; import {
import { useState, type ForwardRefExoticComponent } from "react"; Link,
useLocation,
useNavigate,
type NavigateFunction,
} from "react-router";
import { useEffect, useState, type ForwardRefExoticComponent } from "react";
import { routes } from "Routes/Routes"; import { routes } from "Routes/Routes";
import keycloak from "Api/Keycloak/Keycloak"; import keycloak from "Api/Keycloak/Keycloak";
import { useAuthenticationStore } from "Stores/AuthenticationStore"; import { useAuthenticationStore } from "Stores/AuthenticationStore";
@@ -33,6 +38,7 @@ interface SubtleLinkButtonProps {
text: string; text: string;
color: string; color: string;
selected?: boolean; selected?: boolean;
nav: NavigateFunction;
} }
function SubtleLinkButton(props: SubtleLinkButtonProps) { function SubtleLinkButton(props: SubtleLinkButtonProps) {
@@ -44,16 +50,48 @@ function SubtleLinkButton(props: SubtleLinkButtonProps) {
color={props.selected ? props.color : "contrast"} color={props.selected ? props.color : "contrast"}
text={props.text} text={props.text}
textAlign="left" textAlign="left"
onClick={() => {
props.nav(props.link);
}}
/> />
</Link> </Link>
); );
} }
function Sidebar() { function Sidebar() {
const { profile, profile_picture_path } = useAuthenticationStore(); const { profile, set_profile_picture_path, profile_picture_path } =
useAuthenticationStore();
const [open, set_open] = useState(false); const [open, set_open] = useState(false);
const theme = useMantineTheme(); const theme = useMantineTheme();
const location = useLocation(); // get current URL const location = useLocation(); // get current URL
const navigate = useNavigate();
useEffect(() => {
if (!profile) return;
const fetchAvatar = async () => {
try {
const response = await fetch(
`${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user/serve/${profile.id}`,
{
headers: {
Authorization: `Bearer ${keycloak.token}`,
},
},
);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
set_profile_picture_path(url);
}
} catch (error) {
console.error("Failed to load avatar:", error);
}
};
fetchAvatar();
}, [profile]);
return ( return (
<div className="sidebar"> <div className="sidebar">
@@ -70,8 +108,7 @@ function Sidebar() {
text="Подтвердить" text="Подтвердить"
onClick={() => onClick={() =>
keycloak.logout({ keycloak.logout({
redirectUri: redirectUri: window.location.origin,
window.location.origin + "/" + import.meta.env.VITE_BASE_PATH,
}) })
} }
textSize="lg" textSize="lg"
@@ -94,6 +131,7 @@ function Sidebar() {
text="Эксперименты" text="Эксперименты"
color={theme.colors.teal[7]} color={theme.colors.teal[7]}
selected={location.pathname.startsWith(routes.ExperimentsPage.path)} selected={location.pathname.startsWith(routes.ExperimentsPage.path)}
nav={navigate}
/> />
<SubtleLinkButton <SubtleLinkButton
link={routes.MachinesPage.path} link={routes.MachinesPage.path}
@@ -101,6 +139,7 @@ function Sidebar() {
text="Вычислительные системы" text="Вычислительные системы"
color={theme.colors.violet[7]} color={theme.colors.violet[7]}
selected={location.pathname.startsWith(routes.MachinesPage.path)} selected={location.pathname.startsWith(routes.MachinesPage.path)}
nav={navigate}
/> />
<SubtleLinkButton <SubtleLinkButton
link={routes.TeamsPage.path} link={routes.TeamsPage.path}
@@ -108,6 +147,7 @@ function Sidebar() {
text="Команды" text="Команды"
color={theme.colors.grape[7]} color={theme.colors.grape[7]}
selected={location.pathname.startsWith(routes.TeamsPage.path)} selected={location.pathname.startsWith(routes.TeamsPage.path)}
nav={navigate}
/> />
<SubtleLinkButton <SubtleLinkButton
link={routes.DocumentationPage.path} link={routes.DocumentationPage.path}
@@ -115,6 +155,7 @@ function Sidebar() {
text="Документация" text="Документация"
color={theme.colors.blue[7]} color={theme.colors.blue[7]}
selected={location.pathname.startsWith(routes.DocumentationPage.path)} selected={location.pathname.startsWith(routes.DocumentationPage.path)}
nav={navigate}
/> />
</div> </div>
<div className="sidebar_bottom"> <div className="sidebar_bottom">
@@ -134,15 +175,16 @@ function Sidebar() {
style={{ width: "100%", height: "5px", margin: "5px" }} style={{ width: "100%", height: "5px", margin: "5px" }}
/> />
<div className="sidebar_bottom_bottom"> <div className="sidebar_bottom_bottom">
<Link to={routes.SettingsPage.path} className="invisible_link">
<CustomButton <CustomButton
style="subtle" style="subtle"
icon={<IconSettings2 size={26} />} icon={<IconSettings2 size={26} />}
text="Настройки" text="Настройки"
color="contrast" color="contrast"
textSize="lg" textSize="lg"
onClick={() => {
navigate(routes.SettingsPage.path);
}}
/> />
</Link>
<CustomButton <CustomButton
style="subtle" style="subtle"
icon={<IconLogout size={26} />} icon={<IconLogout size={26} />}

View File

@@ -1,24 +1,45 @@
import { Card, Pill, SimpleGrid, Text, UnstyledButton } from "@mantine/core"; import { Card, Pill, SimpleGrid, Text, UnstyledButton } from "@mantine/core";
import "./ExperimentsListCard.css"; import "./ExperimentsListCard.css";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { Experiment, TaskData } from "Types/Experiment/Experiment"; import type {
ExperimentData,
SimpleInstanceData,
} from "Types/Experiment/Experiment";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { useExperimentStore } from "Stores/ExperimentStore"; import { useExperimentStore } from "Stores/ExperimentStore";
import { deleteExperiment } from "Api/QuantumBackend/ExperimentsManagment";
function ExperimentsListCard(props: { function ExperimentsListCard(props: { experiment: ExperimentData }) {
experiment: Experiment;
team: { team_id: number; team_name: string } | undefined;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { removeExperiment, tasks } = useExperimentStore(); const { removeExperiment } = useExperimentStore();
const experiment_tasks = tasks.filter( const getStatusColor = (status: string) => {
(a) => a.id in props.experiment.tasks_ids, const statusLower = status;
);
switch (statusLower) {
case "draft":
return "var(--mantine-color-gray-5)";
case "in queue":
return "var(--mantine-color-blue-5)";
case "processing":
case "running":
return "var(--mantine-color-yellow-5)";
case "complete":
return "var(--mantine-color-green-5)";
case "error":
case "complete with error":
case "failed":
return "var(--mantine-color-red-5)";
default:
return "var(--mantine-color-gray-5)";
}
};
const handleDelete = () => { const handleDelete = () => {
deleteExperiment(props.experiment.id).then(() => {
removeExperiment(props.experiment.id); removeExperiment(props.experiment.id);
});
}; };
return ( return (
@@ -34,30 +55,38 @@ function ExperimentsListCard(props: {
Эксперимент: {props.experiment.name} Эксперимент: {props.experiment.name}
</Text> </Text>
<Text mb="sm" size="md"> <Text mb="sm" size="md">
Команда: {props.team?.team_name} Команда: {props.experiment.team?.team_name}
</Text> </Text>
<div style={{ display: "flex", gap: "10px" }}> <div style={{ display: "flex", gap: "10px" }}>
<Text size="md">Статус: </Text> <Text size="md">Статус: </Text>
<Pill style={{ backgroundColor: "var(--mantine-color-yellow-5)" }}> <Pill
<Text size="md">{props.experiment.experiment_status}</Text> style={{
backgroundColor: getStatusColor(
props.experiment.status != undefined
? props.experiment.status.toLowerCase()
: "DRAFT",
),
color: "white",
}}
>
<Text size="md">{props.experiment.status}</Text>
</Pill> </Pill>
</div> </div>
</div> </div>
<div className="ExperimentSectionWithLine"> <div className="ExperimentSectionWithLine">
<SimpleGrid cols={2} verticalSpacing="0px"> <SimpleGrid cols={2} verticalSpacing="0px" style={{ rowGap: "4px" }}>
<Text>Задачи:</Text> <Text size="sm">Задачи:</Text>
{experiment_tasks.map( {props.experiment.instance_preview.slice(0, 6).map(
( (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
task: TaskData<any>, instance: SimpleInstanceData,
) => { ) => {
return ( return (
<Pill <Pill
size="md" size="sm"
className="ExperimentPill"
style={{ style={{
backgroundColor: "transparent",
border: "2px solid black",
alignContent: "center", alignContent: "center",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -65,40 +94,50 @@ function ExperimentsListCard(props: {
}} }}
> >
<Text <Text
size="md" size="sm"
style={{ style={{
textWrap: "nowrap", textWrap: "nowrap",
textOverflow: "ellipsis", textOverflow: "ellipsis",
overflow: "hidden", overflow: "hidden",
}} }}
> >
{task.name} {instance.name}
</Text> </Text>
</Pill> </Pill>
); );
}, },
)} )}
{experiment_tasks.length == 0 ? ( {props.experiment.instances_count == 0 ? (
<Pill size="md" className="ExperimentPill2"> <Pill size="sm" className="ExperimentPill2">
Нет Задач Нет Задач
</Pill> </Pill>
) : ( ) : (
<></> <></>
)} )}
{props.experiment.instances_count >
props.experiment.instance_preview.length ? (
<Pill size="sm" className="ExperimentPill2">
и еще{" "}
{props.experiment.instances_count -
props.experiment.instance_preview.length}
</Pill>
) : (
<></>
)}
</SimpleGrid> </SimpleGrid>
</div> </div>
<div className="RightExperimentSection"> <div className="RightExperimentSection">
<div className="TopRightContainer"> <div className="TopRightContainer">
<div className="dateContainer"> <div className="dateContainer">
<Text size="sm"> <Text size="sm">
{new Date( {new Date(props.experiment.created_at + "Z").toLocaleDateString(
props.experiment.date_created + "Z", "ru",
).toLocaleDateString("ru")}{" "} )}{" "}
</Text> </Text>
<Text size="sm"> <Text size="sm">
{new Date( {new Date(props.experiment.created_at + "Z").toLocaleTimeString(
props.experiment.date_created + "Z", "ru",
).toLocaleTimeString("ru")} )}
</Text> </Text>
</div> </div>
<UnstyledButton <UnstyledButton
@@ -122,7 +161,7 @@ function ExperimentsListCard(props: {
> >
<Text> Тип эксперимента: </Text> <Text> Тип эксперимента: </Text>
<Pill className="ExperimentPill"> <Pill className="ExperimentPill">
{props.experiment.experiment_type} {props.experiment.experiment_type.name}
</Pill> </Pill>
</div> </div>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">

View File

@@ -64,7 +64,8 @@ function MachinesListCard(props: SystemWithTeams) {
//className="MachinePill" //className="MachinePill"
style={{ style={{
marginLeft: "10px", marginLeft: "10px",
backgroundColor: colors[props.system.status || "ONLINE"], backgroundColor:
colors[props.system.status as "ONLINE" | "OFFLINE" | "BUSY"],
}} }}
> >
<Text size="md">{props.system.status}</Text> <Text size="md">{props.system.status}</Text>
@@ -82,12 +83,12 @@ function MachinesListCard(props: SystemWithTeams) {
</Text> </Text>
<div style={{ display: "flex", gap: "10px", justifyContent: "right" }}> <div style={{ display: "flex", gap: "10px", justifyContent: "right" }}>
<Text size="sm"> <Text size="sm">
{new Date(props.system.created_at + "Z").toLocaleDateString( {new Date(props.system.last_updated + "Z").toLocaleDateString(
"ru", "ru",
)}{" "} )}{" "}
</Text> </Text>
<Text size="sm"> <Text size="sm">
{new Date(props.system.created_at + "Z").toLocaleTimeString("ru")} {new Date(props.system.last_updated + "Z").toLocaleTimeString("ru")}
</Text> </Text>
</div> </div>
</Card.Section> </Card.Section>

View File

@@ -0,0 +1,28 @@
.InstancesListCard {
height: 120px;
}
.InstanceSectionWithLine {
width: 250px;
}
.InstanceIframeSection {
flex-grow: 1;
position: relative;
}
.DatesContainer {
display: flex;
flex-direction: column;
}
.RightInstanceSection {
display: flex;
flex-direction: row;
width: 275px;
justify-content: space-between;
}
.MachinePill {
background-color: var(--mantine-color-contrast-filled);
color: var(--mantine-color-primary-filled);
}

View File

@@ -0,0 +1,176 @@
import { Card, Pill, Text, UnstyledButton } from "@mantine/core";
import { useNavigate } from "react-router";
import type { InstanceData } from "Types/Experiment/Experiment";
import { IconCheck, IconTrash } from "@tabler/icons-react";
import type { MouseEvent } from "react";
import { IframePlugin } from "Api/PluginLoader/PluginLoader";
import "./TaskListCard.css";
import { deleteInstance } from "Api/QuantumBackend/ExperimentsManagment";
import { useExperimentStore } from "Stores/ExperimentStore";
import { notifications } from "@mantine/notifications";
function InstancesListCard(props: { instance: InstanceData; plugin: string }) {
const navigate = useNavigate();
const { removeInstance } = useExperimentStore();
const getStatusColor = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "draft":
return "var(--mantine-color-gray-5)";
case "in queue":
return "var(--mantine-color-blue-5)";
case "processing":
case "running":
return "var(--mantine-color-yellow-5)";
case "complete":
case "completed":
return "var(--mantine-color-green-5)";
case "error":
case "failed":
return "var(--mantine-color-red-5)";
default:
return "var(--mantine-color-gray-5)";
}
};
const handleDelete = () => {
deleteInstance(props.instance.instance_id).then(() => {
removeInstance(props.instance.instance_id);
notifications.show({
radius: "md",
title: "Задача удалена успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
});
};
return (
<Card
className="InstancesListCard"
orientation="horizontal"
onClick={(ev) => {
console.log("A");
ev.stopPropagation();
navigate(props.instance.instance_id.toString());
}}
>
<Card.Section
withBorder
inheritPadding
px="md"
className="InstanceSectionWithLine"
>
<Text mb="sm" size="md" style={{ textDecorationLine: "underline" }}>
Задача: {props.instance.name}
</Text>
<div style={{ display: "flex", gap: "10px" }}>
<Text size="md">Статус: </Text>
<Pill
style={{
backgroundColor: getStatusColor(
props.instance.simulation_result?.status || "",
),
color: "white",
}}
>
<Text size="md">
{props.instance.simulation_result?.status || "DRAFT"}
</Text>
</Pill>
</div>
<div style={{ display: "flex", gap: "10px" }}>
<Text size="md">ВС: </Text>
<Pill className="MachinePill">
<Text size="md">
{props.instance.simulation_result?.comp_system?.system_name ||
"--"}
</Text>
</Pill>
</div>
</Card.Section>
<Card.Section withBorder className="InstanceIframeSection">
{JSON.stringify(props.instance.instance_data) && (
<IframePlugin
plugin={props.plugin}
mode="List"
taskData={JSON.stringify(props.instance.instance_data)}
index={props.instance.instance_id}
qubits_needed={0}
simProgress={JSON.stringify(
props.instance.simulation_result?.simulation_result,
)}
/>
)}
</Card.Section>
<Card.Section className="RightInstanceSection" inheritPadding px="sm">
<div className="DatesContainer">
<div className="dateContainer">
<Text size="sm">Время начала:</Text>
{props.instance.simulation_result ? (
<>
<Text size="sm">
{new Date(
props.instance.simulation_result?.started_at + "Z",
).toLocaleDateString("ru")}{" "}
</Text>
<Text size="sm">
{new Date(
props.instance.simulation_result?.started_at + "Z",
).toLocaleTimeString("ru")}
</Text>
</>
) : (
<Text size="sm">--</Text>
)}
</div>
<div className="dateContainer">
<Text size="sm">Время окончания:</Text>
{props.instance.simulation_result?.ended_at ? (
<>
<Text size="sm">
{new Date(
props.instance.simulation_result?.ended_at + "Z",
).toLocaleDateString("ru")}{" "}
</Text>
<Text size="sm">
{new Date(
props.instance.simulation_result?.ended_at + "Z",
).toLocaleTimeString("ru")}
</Text>
</>
) : (
<Text size="sm">--</Text>
)}
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
paddingLeft: "10px",
}}
>
<UnstyledButton
onClick={(e: MouseEvent) => {
e.stopPropagation();
handleDelete();
}}
style={{ cursor: "pointer" }}
>
<IconTrash size={20} />
</UnstyledButton>
<Text c="dimmed" size="sm">
#{props.instance.instance_id}
</Text>
</div>
</Card.Section>
</Card>
);
}
export default InstancesListCard;

View File

@@ -1,16 +1,26 @@
import { Card, Text, UnstyledButton } from "@mantine/core"; import { Card, NumberInput, Text, UnstyledButton } from "@mantine/core";
import { IconCancel, IconCheck, IconTrash } from "@tabler/icons-react"; import {
import type { MouseEvent } from "react"; IconCancel,
IconCheck,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import { useState, type MouseEvent } from "react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import type { SystemTeamData, SystemWithTeams } from "Types/Machine/Machine"; import type { SystemTeamData, SystemWithTeams } from "Types/Machine/Machine";
import { removeSystemFromTeam } from "Api/QuantumBackend/MachineManagment"; import {
giveSystemToTeam,
removeSystemFromTeam,
} from "Api/QuantumBackend/MachineManagment";
import { useDeviceStore } from "Stores/DeviceStore"; import { useDeviceStore } from "Stores/DeviceStore";
function TeamInMachineListCard(props: { function TeamInMachineListCard(props: {
team: SystemTeamData; team: SystemTeamData;
system: SystemWithTeams; system: SystemWithTeams;
}) { }) {
const [isEditing, setIsEditing] = useState(false);
const { updateDevice } = useDeviceStore(); const { updateDevice } = useDeviceStore();
const [count, setCount] = useState(props.team.num_qubits);
const handleRemovePerm = () => { const handleRemovePerm = () => {
// TODO: fix delete // TODO: fix delete
@@ -76,7 +86,69 @@ function TeamInMachineListCard(props: {
flexDirection: "row", flexDirection: "row",
}} }}
> >
<Text>Количество кубит: {props.team.num_qubits}</Text> {isEditing && (
<>
<Text>Количество кубит: </Text>
<NumberInput
value={count}
onChange={(ev) => {
setCount(Number(ev.valueOf()));
}}
></NumberInput>
<UnstyledButton
onClick={() => {
giveSystemToTeam({
system_id: props.system.system.id,
team_id: props.team.team.team_id,
qubits_given: count,
}).then(() => {
const team = props.system.teams.find((t) => {
return t.team.team_id == props.team.team.team_id;
});
if (team) {
const updatedItem = { ...team, num_qubits: count };
// Create new array with the updated item in the same position
const updatedItems = props.system.teams.map(
(currentItem) =>
currentItem.team.team_id === team.team.team_id
? updatedItem
: currentItem,
);
updateDevice(props.system.system.id, {
teams: updatedItems,
});
}
setIsEditing(false);
});
}}
>
<IconCheck size={25} />
</UnstyledButton>
<UnstyledButton
onClick={() => {
setCount(props.team.num_qubits);
setIsEditing(false);
}}
>
<IconCancel size={25} />
</UnstyledButton>
</>
)}
{!isEditing && (
<>
<Text>Количество кубит: {count}</Text>
<UnstyledButton
onClick={() => {
setIsEditing(true);
}}
>
<IconPencil size={25} />
</UnstyledButton>
</>
)}
</Card.Section> </Card.Section>
<Card.Section <Card.Section
inheritPadding inheritPadding

View File

@@ -8,6 +8,7 @@ import {
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconCancel,
IconCheck, IconCheck,
IconCrown, IconCrown,
IconForbid, IconForbid,
@@ -196,7 +197,8 @@ function TeamMemberCard(props: { cur_team: Team; member: TeamMember }) {
deleteMember({ deleteMember({
team_id: props.cur_team.id, team_id: props.cur_team.id,
user_id: props.member.user.keycloak_id, user_id: props.member.user.keycloak_id,
}).then(() => { })
.then(() => {
notifications.show({ notifications.show({
radius: "md", radius: "md",
title: "Пользователь удален успешно", title: "Пользователь удален успешно",
@@ -208,10 +210,21 @@ function TeamMemberCard(props: { cur_team: Team; member: TeamMember }) {
members: [ members: [
...props.cur_team.members.filter( ...props.cur_team.members.filter(
(member) => (member) =>
member.user.keycloak_id != props.member.user.keycloak_id, member.user.keycloak_id !=
props.member.user.keycloak_id,
), ),
], ],
}); });
})
.catch(() => {
notifications.show({
radius: "md",
title: "Пользователя не получилось удалить",
message: "",
color: "red",
icon: <IconCancel />,
style: { paddingLeft: "5px" },
});
}); });
}} }}
style={{ style={{

View File

@@ -11,6 +11,8 @@ import CustomButton from "Components/CustomButton/CustomButton";
import { useDeviceStore } from "Stores/DeviceStore"; import { useDeviceStore } from "Stores/DeviceStore";
import type { SystemWithTeams } from "Types/Machine/Machine"; import type { SystemWithTeams } from "Types/Machine/Machine";
import { giveSystemToTeam } from "Api/QuantumBackend/MachineManagment"; import { giveSystemToTeam } from "Api/QuantumBackend/MachineManagment";
import { notifications } from "@mantine/notifications";
import { IconCancel } from "@tabler/icons-react";
interface AddTeamToDeviceProps { interface AddTeamToDeviceProps {
isOpened: boolean; isOpened: boolean;
@@ -20,7 +22,7 @@ interface AddTeamToDeviceProps {
} }
export function AddTeamToDevice(props: AddTeamToDeviceProps) { export function AddTeamToDevice(props: AddTeamToDeviceProps) {
const [numQubits, setNumQubits] = useState(0); const [numQubits, setNumQubits] = useState(1);
const { updateDevice } = useDeviceStore(); const { updateDevice } = useDeviceStore();
const [selectedTeam, setSelectedTeam] = useState<{ const [selectedTeam, setSelectedTeam] = useState<{
label: string; label: string;
@@ -29,7 +31,7 @@ export function AddTeamToDevice(props: AddTeamToDeviceProps) {
//reset on open dialog //reset on open dialog
useEffect(() => { useEffect(() => {
if (props.isOpened) { if (props.isOpened) {
setNumQubits(0); setNumQubits(1);
setSelectedTeam(undefined); setSelectedTeam(undefined);
} }
}, [props.isOpened]); }, [props.isOpened]);
@@ -63,14 +65,23 @@ export function AddTeamToDevice(props: AddTeamToDeviceProps) {
}); });
props.setIsOpened(false); props.setIsOpened(false);
}) })
.catch(() => {}); .catch(() => {
notifications.show({
radius: "md",
title: "Не удалось предоставить доступ",
message: "",
color: "red",
icon: <IconCancel />,
style: { paddingLeft: "5px" },
});
});
}; };
return ( return (
<Modal <Modal
opened={props.isOpened} opened={props.isOpened}
onClose={handleClose} onClose={handleClose}
title=<Title size="xl">Добавить члена команды</Title> title=<Title size="xl">Предоставить команде права на ВС</Title>
centered centered
size="75%" size="75%"
styles={{ styles={{
@@ -98,11 +109,13 @@ export function AddTeamToDevice(props: AddTeamToDeviceProps) {
/> />
<NumberInput <NumberInput
value={numQubits} value={numQubits}
label="Количество кубит" label={`Количество кубит (Макс ${props.device.system.max_qubits})`}
required required
onChange={(event) => { onChange={(event) => {
setNumQubits(Number(event.valueOf())); setNumQubits(Number(event.valueOf()));
}} }}
max={props.device.system.max_qubits}
min={1}
></NumberInput> ></NumberInput>
<Space h="md" /> <Space h="md" />
<Center> <Center>

View File

@@ -9,23 +9,33 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import "./NewExperiment.css"; import "./NewExperiment.css";
import { useExperimentStore } from "Stores/ExperimentStore"; //import { useExperimentStore } from "Stores/ExperimentStore";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { createExperiment } from "Api/QuantumBackend/ExperimentsManagment";
import { useExperimentStore } from "Stores/ExperimentStore";
interface NewExperimentModalProps { interface NewExperimentModalProps {
isOpened: boolean; isOpened: boolean;
setIsOpened: (opened: boolean) => void; setIsOpened: (opened: boolean) => void;
teams: { team_id: number; team_name: string }[] | undefined; teams: { team_id: number; team_name: string }[] | undefined;
types: { type_id: number; type_name: string }[] | undefined;
} }
export function NewExperimentModal(props: NewExperimentModalProps) { export function NewExperimentModal(props: NewExperimentModalProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const { addExperiment } = useExperimentStore(); //const { addExperiment } = useExperimentStore();
const [selectedTeam, setSelectedTeam] = useState<{ const [selectedTeam, setSelectedTeam] = useState<{
label: string; label: string;
value: string; value: string;
}>(); }>();
const [selectedType, setSelectedType] = useState<{
label: string;
value: string;
}>();
const { addExperiment } = useExperimentStore();
//reset on open dialog //reset on open dialog
useEffect(() => { useEffect(() => {
if (props.isOpened) { if (props.isOpened) {
@@ -41,18 +51,16 @@ export function NewExperimentModal(props: NewExperimentModalProps) {
const handleCreateExperiment = () => { const handleCreateExperiment = () => {
//TODO: add logic for backend server //TODO: add logic for backend server
if (selectedTeam) { if (selectedTeam && selectedType) {
addExperiment({ createExperiment({
id: 1, team_id: Number(selectedTeam.value),
name: name, name: name,
description: description, description: description,
team_id: Number(selectedTeam?.value), experiment_type_id: Number(selectedType.value),
tasks_ids: [1, 2], }).then((exp) => {
date_created: new Date(), addExperiment(exp);
experiment_status: "PROCESSING",
experiment_type: "VQE",
});
props.setIsOpened(false); props.setIsOpened(false);
});
} }
}; };
@@ -109,9 +117,32 @@ export function NewExperimentModal(props: NewExperimentModalProps) {
onChange={(_value, option) => setSelectedTeam(option)} onChange={(_value, option) => setSelectedTeam(option)}
/> />
<Space h="md" /> <Space h="md" />
<Select
value={selectedType?.value}
label="Тип эксперимента"
placeholder="Выберите тип эксперимента"
searchable
required
data={
props.types
? props.types.map((team) => {
return {
value: team.type_id.toString(),
label: `${team.type_name} (#${team.type_id})`,
};
})
: []
}
onChange={(_value, option) => setSelectedType(option)}
/>
<Space h="md" />
<Center> <Center>
<CustomButton <CustomButton
disabled={name != "" && selectedTeam ? false : true} disabled={
name == "" ||
(selectedTeam ? false : true) ||
(selectedType ? false : true)
}
color="contrast" color="contrast"
onClick={handleCreateExperiment} onClick={handleCreateExperiment}
text="Создать эксперимент" text="Создать эксперимент"

View File

@@ -1,3 +0,0 @@
.StepperRoot {
flex-direction: row-reverse !important;
}

View File

@@ -1,241 +0,0 @@
import {
Button,
Center,
Modal,
Space,
Stepper,
Title,
Text,
Flex,
Select,
Textarea,
} from "@mantine/core";
import { useEffect, useState } from "react";
import "./NewMolecule.css";
import {
IconArchiveFilled,
IconArticleFilled,
IconFileFilled,
} from "@tabler/icons-react";
import {
ConvertMoleculeToStandart,
GetInFormats,
} from "Api/ConvertBackendCalls";
import { AxiosError } from "axios";
import CustomButton from "Components/CustomButton/CustomButton";
interface NewMoleculeModalProps {
isOpened: boolean;
setIsOpened: (opened: boolean) => void;
}
export function NewMoleculeModal(props: NewMoleculeModalProps) {
const [activeStep, setActiveStep] = useState(0);
const [selectedMethod, setSelectedMethod] = useState(0);
const [inMolecule, setInMolecule] = useState("");
const [inFormat, setInFormat] = useState<string | null>();
const [inOptions, setInOptions] = useState<string[] | undefined>();
const getOptions = () => {
GetInFormats().then((data: { [key: string]: string } | AxiosError) => {
if (data instanceof AxiosError) {
//TODO: handle Error
} else {
const values = Object.keys(data);
setInOptions(values);
}
});
};
useEffect(() => {
getOptions();
}, []);
//reset on open dialog
useEffect(() => {
if (props.isOpened) {
setActiveStep(0);
setSelectedMethod(0);
setInMolecule("");
setInFormat(undefined);
if (inOptions == undefined) {
getOptions();
}
}
}, [props.isOpened]);
const handleClose = () => {
props.setIsOpened(false);
};
const handleConvert = () => {
if (inFormat) {
ConvertMoleculeToStandart({
inputText: inMolecule,
inputFormat: inFormat,
make_3d: true,
add_h: false,
optimize: false,
}).then((data: string | AxiosError) => {
if (data instanceof AxiosError) {
//TODO: handle Error
} else {
setCurrentMoleculeString(data);
props.setIsOpened(false);
}
});
} else {
//TODO: add no format selected handling
}
};
const handleEmptyMolecule = () => {
props.setIsOpened(false);
};
return (
<Modal
opened={props.isOpened}
onClose={handleClose}
title=<Title size="xl">Новая молекула</Title>
centered
size="75%"
styles={{
content: { paddingLeft: "10px" },
title: { width: "100%" },
}}
>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
allowNextStepsSelect={false}
classNames={{ root: "StepperRoot" }}
styles={{
steps: {
paddingTop: "30px",
paddingBottom: "30px",
paddingLeft: "20px",
minWidth: "0px",
alignSelf: "center",
},
content: {
flex: 1,
},
}}
style={{ display: "flex", flexDirection: "row" }}
orientation="vertical"
>
<Stepper.Step>
<div style={{ width: "100%", textAlign: "center" }}>
<Title size="lg">Шаг 1: Способ добавления молекулы</Title>
<Space h="lg" />
<Flex justify="center" gap="md" wrap="wrap">
<Button
onClick={() => {
setSelectedMethod(1);
handleEmptyMolecule();
}}
style={{ height: "100px", flexGrow: "1" }}
size="lg"
>
<Flex direction="row" gap="lg">
<IconFileFilled size={40} />
<Center>
<Text>Пустая молекула</Text>
</Center>
</Flex>
</Button>
<Button
onClick={() => {
setSelectedMethod(2);
setActiveStep(1);
}}
style={{ height: "100px", flexGrow: "1" }}
>
<Flex direction="row" gap="lg">
<IconArchiveFilled size="40" />
<Center>
<Text>Из файла / Архива</Text>
</Center>
</Flex>
</Button>
<Button
onClick={() => {
setSelectedMethod(3);
setActiveStep(1);
}}
style={{ height: "100px", flexGrow: "1" }}
>
<Flex direction="row" gap="lg">
<IconArticleFilled size={40} />
<Center>
<Text>Другой формат</Text>
</Center>
</Flex>
</Button>
</Flex>
</div>
</Stepper.Step>
<Stepper.Step>
<div style={{ width: "100%", textAlign: "center" }}>
{selectedMethod == 2 && (
<div>
<Title size="lg">Шаг 2: Выбор файла</Title>
<Space h="lg" />
</div>
)}
{selectedMethod == 3 && (
<div>
<Title size="md">Шаг 2: Ввод молекулы</Title>
<Space h="lg" />
<Textarea
className="moleculeInput"
value={inMolecule}
onChange={(e) => setInMolecule(e.currentTarget.value)}
placeholder="Введите текст молекулы"
classNames={{
wrapper: "moleculeInputWrapper",
}}
onKeyDown={(e) => {
// Stop arrow keys from reaching react-resizable-panels
if (
[
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
].includes(e.key)
) {
e.stopPropagation();
}
}}
/>
<Space h="md" />
<Select
value={inFormat}
onChange={(value: string | null) => setInFormat(value)}
searchable
data={inOptions}
placeholder="Выберите формат"
classNames={{
input: "selectInFormat",
dropdown: "selectDropDown",
option: "selectDropDownOption",
}}
/>
<Space h="md" />
<CustomButton
color="accent"
onClick={handleConvert}
text="Преобразовать"
/>
</div>
)}
</div>
</Stepper.Step>
</Stepper>
</Modal>
);
}

View File

View File

@@ -0,0 +1,127 @@
import {
Center,
Modal,
Space,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import "./NewTask.css";
import CustomButton from "Components/CustomButton/CustomButton";
import { IconCheck } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { useExperimentStore } from "Stores/ExperimentStore";
import { createInstance } from "Api/QuantumBackend/ExperimentsManagment";
interface NewInstanceModalProps {
isOpened: boolean;
setIsOpened: (opened: boolean) => void;
experiment_id: number;
}
export function NewInstanceModal(props: NewInstanceModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const { setInstances, instances } = useExperimentStore();
const [isLoading, setIsLoading] = useState(false);
//reset on open dialog
useEffect(() => {
if (props.isOpened) {
setName("");
setDescription("");
}
}, [props.isOpened]);
const handleClose = () => {
props.setIsOpened(false);
};
const handleCreateTeam = () => {
//TODO: add logic for backend server
//
setIsLoading(true);
createInstance({
experiment_id: props.experiment_id,
name: name,
description: description,
instance_data: "{}",
}).then((instance) => {
setIsLoading(false);
if (instance) {
setInstances([
{
instance_id: instance.id,
name: instance.name,
instance_data: "{}",
description: instance.description,
qubits_needed: 0,
},
...instances,
]);
notifications.show({
radius: "md",
title: "Задача создана успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
props.setIsOpened(false);
}
});
};
return (
<Modal
opened={props.isOpened}
onClose={handleClose}
title=<Title size="xl">Новая Задача</Title>
centered
size="75%"
styles={{
content: { paddingLeft: "10px" },
title: { width: "100%" },
}}
>
<TextInput
value={name}
label="Имя задач"
placeholder="Введите имя задачи"
required
onChange={(event) => {
setName(event.currentTarget.value);
}}
></TextInput>
<Space h="md" />
<Textarea
value={description}
label="Описание задачи"
placeholder="Введите описание задачи"
minRows={4}
maxRows={10}
autosize
onChange={(event) => {
setDescription(event.currentTarget.value);
}}
></Textarea>
<Space h="md" />
<Center>
<CustomButton
disabled={(name != "" ? false : true) || isLoading}
color="contrast"
onClick={handleCreateTeam}
text="Создать Задачу"
></CustomButton>
<Space w="md" />
<CustomButton
color="contrast"
style="outline"
onClick={() => {
props.setIsOpened(false);
}}
text="Отменить"
></CustomButton>
</Center>
</Modal>
);
}

View File

@@ -21,6 +21,12 @@ import TeamInMachineListCard from "Components/ListCard/TeamListCard/TeamInMachin
import { AddTeamToDevice } from "Modals/AddTeamToDevice/AddTeamToDevice"; import { AddTeamToDevice } from "Modals/AddTeamToDevice/AddTeamToDevice";
import { getShortTeamsList } from "Api/QuantumBackend/TeamManagement"; import { getShortTeamsList } from "Api/QuantumBackend/TeamManagement";
const colors = {
ONLINE: "var(--mantine-color-green-7)",
OFFLINE: "var(--mantine-color-red-7)",
BUSY: "var(--mantine-color-yellow-5)",
};
function DevicePage() { function DevicePage() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { device_id } = useParams(); const { device_id } = useParams();
@@ -107,7 +113,16 @@ function DevicePage() {
<Space h="xl" /> <Space h="xl" />
<div style={{ display: "flex", flexDirection: "row", gap: "15px" }}> <div style={{ display: "flex", flexDirection: "row", gap: "15px" }}>
<Text size="md">Статус: </Text> <Text size="md">Статус: </Text>
<Pill className="ExperimentPill"> <Pill
className="ExperimentPill"
style={{
color: "white",
backgroundColor:
colors[
cur_device?.system.status as "ONLINE" | "OFFLINE" | "BUSY"
],
}}
>
<Text size="md">{cur_device?.system.status}</Text> <Text size="md">{cur_device?.system.status}</Text>
</Pill> </Pill>
<div className="dateContainer"> <div className="dateContainer">

View File

@@ -1,4 +1,13 @@
import { Box, Divider, TableOfContents, Title } from "@mantine/core"; import {
Box,
Divider,
TableOfContents,
Title,
Text,
List,
Code,
Anchor,
} from "@mantine/core";
import "./DocumentationPage.css"; import "./DocumentationPage.css";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
@@ -6,15 +15,19 @@ function DocumentationPage() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Documentation | QMolSim</title> <title>Документация | QMolSim</title>
<meta <meta
name="description" name="description"
content="See the documentation on how to setup and use the qunatum computational system" content="Полное руководство пользователя по системе распределенного квантово-химического расчета QMolSim."
/> />
</Helmet> </Helmet>
<Title order={1} className="docTitle"> <Title order={1} className="docTitle">
Документация Руководство пользователя
</Title> </Title>
<Text size="sm" c="dimmed" mb="md">
Автоматизированная система распределенного расчета энергии основного
состояния молекул
</Text>
<Divider /> <Divider />
<div className="DocumentationPage"> <div className="DocumentationPage">
<Box className="tableOfContents" visibleFrom="md"> <Box className="tableOfContents" visibleFrom="md">
@@ -26,24 +39,291 @@ function DocumentationPage() {
minDepthToOffset={0} minDepthToOffset={0}
depthOffset={20} depthOffset={20}
scrollSpyOptions={{ scrollSpyOptions={{
selector: "section h1, h2", selector: "section h1, section h2, section h3",
}} }}
className=""
getControlProps={({ data }) => ({ getControlProps={({ data }) => ({
onClick: () => onClick: () =>
data data
.getNode() .getNode()
.scrollIntoView({ behavior: "smooth", block: "center" }), .scrollIntoView({ behavior: "smooth", block: "start" }),
children: data.value, children: data.value,
})} })}
/> />
</Box> </Box>
<div className="contents"> <div className="contents">
<section id="introduction" style={{ height: 1000 }}> {/* ================= 1 ВВЕДЕНИЕ ================= */}
<section id="introduction">
<Title order={1}>1. Введение</Title> <Title order={1}>1. Введение</Title>
<Title order={2} mt="md" id="application-area">
1.1 Область применения
</Title>
<Text>Требования настоящего документа применяются при:</Text>
<List>
<List.Item>предварительных комплексных испытаниях;</List.Item>
<List.Item>опытной эксплуатации;</List.Item>
<List.Item>приемочных испытаниях;</List.Item>
<List.Item>промышленной эксплуатации.</List.Item>
</List>
<Title order={2} mt="md" id="capabilities">
1.2 Краткое описание возможностей
</Title>
<Text>
Автоматизированная система распределенного расчета энергии
основного состояния молекул с помощью квантовых алгоритмов
представляет собой распределенный веб-сервис, предназначенный для
выполнения ресурсоемких квантово-химических расчетов с
использованием гибридной архитектуры, состоящей из центрального
сервера и распределенных квантовых симуляторов.
</Text>
<Title order={3} mt="sm" id="cap-mgmt">
Управление командной работой
</Title>
<Text>
Пользователи могут создавать команды, приглашать других
исследователей, назначать права доступа.
</Text>
<Title order={3} mt="sm" id="cap-nodes">
Подключение вычислительных систем
</Title>
<Text>
Исследователи регистрируют в системе свои вычислительные узлы. Для
каждого узла исследователь задает максимальное количество кубитов,
а также предоставляет доступ командам на использование устройства.
</Text>
<Title order={3} mt="sm" id="cap-experiments">
Создание и запуск экспериментов
</Title>
<Text>
В рамках команды пользователь создает эксперимент (набор задач для
разных молекул). Для каждой задачи загружается или редактируется
структура молекулы в формате XYZ, задаются квантово-химические
параметры.
</Text>
<Title order={3} mt="sm" id="cap-vqe">
Распределенные вычисления VQE
</Title>
<Text>
При запуске эксперимента система автоматически распределяет задачи
по доступным вычислительным узлам с учетом их ограничений по числу
кубит. В процессе расчета на сервер передаются промежуточные
результаты.
</Text>
<Title order={3} mt="sm" id="cap-fault">
Отказоустойчивость и восстановление
</Title>
<Text>
Каждый вычислительный узел каждые 5 секунд отправляет сигнал о
своей работоспособности. При выходе узла из строя незавершенная
задача автоматически перенаправляется в очередь и назначается на
другой узел с сохранением промежуточных весов оптимизации.
</Text>
<Title order={3} mt="sm" id="cap-vis">
Визуализация молекул
</Title>
<Text>
Для каждой задачи доступна интерактивная 3D-визуализация молекулы
в шаростержневой модели.
</Text>
<Title order={3} mt="sm" id="cap-import">
Импорт молекулярных данных
</Title>
<Text>
Система поддерживает преобразование молекул в требуемый формат из
большинства существующих химических форматов.
</Text>
</section> </section>
<section id="quick-start" style={{ height: 1000 }}>
<Title order={2}>1.1 Быстрое начало</Title> {/* ================= 2 НАЗНАЧЕНИЕ И УСЛОВИЯ ================= */}
<section id="purpose-conditions" style={{ marginTop: "2rem" }}>
<Title order={1}>2. Назначение и условия применения</Title>
<Title order={2} mt="md" id="purpose">
2.1 Назначение системы
</Title>
<Text>
Система предназначена для автоматизированного распределенного
расчета энергии основного состояния молекул с использованием
квантового алгоритма VQE. Она обеспечивает создание команд
исследователей с настройкой прав доступа, автоматическое
распределение вычислительных задач между доступными узлами,
мониторинг состояния вычислений и восстановление прогресса расчета
при сбое отдельных вычислительных систем. Применение системы
позволяет повысить скорость проведения квантово-химических
расчетов и снизить нагрузку на пользователя по управлению
вычислительным процессом. Система ориентирована на специалистов в
области квантовой химии и вычислительных технологий.
</Text>
<Title order={2} mt="md" id="tech-reqs">
2.2 Требования к техническим средствам
</Title>
<Title order={3} mt="sm" id="client-browser">
Клиент-браузер:
</Title>
<List>
<List.Item>Оперативная память от 4 Гб;</List.Item>
<List.Item>Свободное пространство на диске от 2 Гб;</List.Item>
<List.Item>Процессор 4-ядерный с частотой от 2 ГГц;</List.Item>
<List.Item>Скорость подключения в интернет от 50 Мб/c;</List.Item>
<List.Item>
Наличие манипулятора "мышь" или аналогичного устройства для
взаимодействия с интерфейсом;
</List.Item>
<List.Item>Наличие Клавиатуры.</List.Item>
</List>
<Title order={3} mt="sm" id="client-compute">
Клиент-ВС:
</Title>
<List>
<List.Item>Оперативная память от 8 Гб;</List.Item>
<List.Item>Свободное пространство на диске от 5 Гб;</List.Item>
<List.Item>
Процессор 8-ядерный с частотой от 2-4,4 ГГц;
</List.Item>
<List.Item>Скорость подключения в интернет от 50 Мб/с;</List.Item>
<List.Item>
Наличие манипулятора "мышь" или аналогичного устройства для
взаимодействия с интерфейсом;
</List.Item>
<List.Item>Наличие Клавиатуры.</List.Item>
</List>
<Title order={2} mt="md" id="software-reqs">
2.3 Требования к программным средствам
</Title>
<Title order={3} mt="sm" id="sw-browser">
Клиент-браузер:
</Title>
<Text>
Браузер (Safari 18.1.1, Яндекс Браузер 25.2.1, Google Chrome
110.0.5481.100)
</Text>
<Title order={3} mt="sm" id="sw-compute">
Клиент-ВС:
</Title>
<List>
<List.Item>ОС Windows 10, Windows 11, MacOS, Linux</List.Item>
<List.Item>
Браузер (Safari 18.1.1, Яндекс Браузер 25.2.1, Google Chrome
110.0.5481.100)
</List.Item>
<List.Item>Docker</List.Item>
<List.Item>Docker-compose v2</List.Item>
</List>
<Title order={2} mt="md" id="exec-conditions">
3. Условия выполнения программы
</Title>
<Text>
Для работы системы требуется веб-браузер, поддерживающий
современные функции JavaScript (Google Chrome версии 110 и выше,
Яндекс Браузер версии 25.2.1 и выше, Safari версии 18.1.1 и выше).
Доступ к системе осуществляется через веб-интерфейс по адресу,
предоставленному администратором. Для работы вычислительных узлов
дополнительно требуется установленный Docker и Docker Compose v2
на каждой подключаемой вычислительной системе. Необходимо наличие
постоянного сетевого подключения к серверу для всех
взаимодействующих компонентов системы.
</Text>
</section>
{/* ================= 4 ВЫПОЛНЕНИЕ ПРОГРАММЫ ================= */}
<section id="execution" style={{ marginTop: "2rem" }}>
<Title order={1}>4. Выполнение программы</Title>
<Title order={2} mt="md" id="install">
4.1 Инсталяция/деинсталяция
</Title>
<Text>
Клиент-браузер инсталляции и деинсталляции не требуется, для
работы необходимо только наличие на системе совместимого браузера.
</Text>
<Title order={3} mt="sm" id="install-compute">
Для инсталляции клиента-ВС:
</Title>
<List>
<List.Item>
На системе необходимо наличие docker и docker-compose v2
</List.Item>
<List.Item>
Необходимо скачать контейнер с помощью команды:{" "}
<Code>docker pull git.deowl.ru/vkrb/client:0.1.0</Code>
</List.Item>
<List.Item>
Затем скачать файл docker-compose с помощью команды:{" "}
<Code>
curl -O
"https://git.deowl.ru/vkrb/local_quantum_simulator/raw/branch/main/docker-compose.yml"
</Code>
</List.Item>
<List.Item>
Наконец, в той же папке необходимо создать файл переменных среды
с названием ".env" и содержимым:
<Code block mt="xs">
{`PORT=5001
STORAGE_PATH="/storage"
RABBITMQ_HOST=rabbit.deowl.ru
RABBITMQ_PORT=5672
KEYCLOAK_URL=https://quantum-auth.deowl.ru
KEYCLOAK_REALM_NAME=quant_sim-realm
KEACLOAK_CLIENT_ID=local_quantum_sim
QUANTUM_BACKEND_URL=https://quantum.deowl.ru`}
</Code>
</List.Item>
</List>
<Title order={3} mt="md" id="uninstall-compute">
Для деинсталляции клиента-ВС:
</Title>
<List>
<List.Item>
Удаляем файлы «docker-compose.yml», «.env» и папку
«localStorage» (при ее наличие)
</List.Item>
<List.Item>
Удаляем установленное изображение с помощью команды:{" "}
<Code>docker image rm git.deowl.ru/vkrb/client:0.1.0</Code>
</List.Item>
</List>
<Title order={2} mt="md" id="start-stop">
4.2 Запуск / Остановка программы
</Title>
<Text>
Клиент-браузер может быть открыт по ссылке:{" "}
<Anchor href="http://quantum.deowl.ru/">
http://quantum.deowl.ru/
</Anchor>
</Text>
<Text mt="sm">
Для запуска клиента-ВС необходимо выполнить команду, находясь в
папке с файлом «docker-compose.yml»:{" "}
<Code>docker compose up --d</Code>
</Text>
<Text mt="sm">
Для остановки клиента-ВС: <Code>docker compose down</Code>
</Text>
<Text mt="sm">
Для первичного подключения и мониторинга статуса клиента-ВС
необходимо открыть ссылку:{" "}
<Anchor href="http://localhost:5001/">
http://localhost:5001/
</Anchor>
</Text>
</section> </section>
</div> </div>
</div> </div>

View File

@@ -2,15 +2,19 @@
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
} }
.experimentButtons { .experimentButtons {
display: flex; display: flex;
gap: 20px;
flex-direction: row; flex-direction: row;
justify-content: right; justify-content: space-between;
width: fit-content;
margin-left: auto; width: 100%;
flex-wrap: nowrap; flex-wrap: nowrap;
text-wrap: nowrap; text-wrap: nowrap;
* {
max-width: 200px;
}
} }

View File

@@ -1,22 +1,221 @@
import { Alert, Center, Text } from "@mantine/core"; import {
Alert,
Center,
LoadingOverlay,
Text,
UnstyledButton,
SimpleGrid,
Title,
TextInput,
Button,
Group,
Card,
Badge,
Collapse,
ActionIcon,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import "./ExperimentPage.css"; import "./ExperimentPage.css";
import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer"; import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer";
//import { NewMoleculeModal } from "Modals/NewMolecule/NewMolecule"; import { useEffect, useState } from "react";
import { useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { IconPlus, IconSettings } from "@tabler/icons-react"; import {
IconPlus,
IconReload,
IconEdit,
IconX,
IconCheck,
IconChevronUp,
IconChevronDown,
IconCancel,
} from "@tabler/icons-react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { useExperimentStore } from "Stores/ExperimentStore"; import { useExperimentStore } from "Stores/ExperimentStore";
import type { ExperimentData, InstanceData } from "Types/Experiment/Experiment";
import { useAuthenticationStore } from "Stores/AuthenticationStore";
import {
getExperimentById,
getExperimentInstances,
getFrontendFile,
startExperiment,
updateExperiment,
} from "Api/QuantumBackend/ExperimentsManagment";
import InstancesListCard from "Components/ListCard/TaskListCard/TaskListCard";
import { NewInstanceModal } from "Modals/NewTask/NewTask";
function ExperimentPage() { function ExperimentPage() {
const [isOpen, setIsOpen] = useState(false); const [isOpened, setIsOpen] = useState(false);
const { experiment_id } = useParams(); const { experiment_id } = useParams();
const { experiments, addTask } = useExperimentStore(); const { experiments, addExperiment, instances, setInstances } =
useExperimentStore();
const experiment = experiments.find((exp) => { const [experiment, set_experiment] = useState<ExperimentData | undefined>(
experiments.find((exp) => {
return exp.id == Number(experiment_id); return exp.id == Number(experiment_id);
}),
);
const { profile, is_loading } = useAuthenticationStore();
const [is_loading_, set_is_loading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editedName, setEditedName] = useState("");
const [editedDesc, setEditedDesc] = useState("");
const [isExpanded, setIsExpanded] = useState(false);
const { loadedHtmlFiles, addLoadedHtml } = useExperimentStore();
const [total_experiment_inst, set_total_experiment_inst] =
useState<number>(0);
const [page_size, set_page_size] = useState<number>(5);
const [cur_page, set_cur_page] = useState<number>(1);
const saveExperimentDetails = () => {
if (experiment && editedName.trim()) {
updateExperiment({
experiment_id: experiment.id,
name: editedName,
description: editedDesc,
})
.then((updated) => {
const updatedExperiment = {
...experiment,
name: updated.name,
description: updated.description,
};
addExperiment(updatedExperiment);
set_experiment(updatedExperiment);
setIsEditing(false);
notifications.show({
title: "Успех",
message: "Информация об эксперименте обновлена",
color: "green",
}); });
})
.catch(() => {
notifications.show({
title: "Ошибка",
message: "Не удалось обновить информацию",
color: "red",
});
});
}
};
const cancelEditing = () => {
if (experiment) {
setEditedName(experiment.name);
setEditedDesc(experiment.description || "");
setIsEditing(false);
}
};
const startEditing = () => {
if (experiment) {
setEditedName(experiment.name);
setEditedDesc(experiment.description || "");
setIsEditing(true);
}
};
const handleExperimentStart = () => {
if (experiment) {
startExperiment({ experiment_id: experiment.id })
.then(() => {
getExperimentById(Number(experiment_id))
.then((exp) => {
addExperiment(exp);
set_experiment(exp);
getExperimentInstances(exp.id, cur_page, page_size)
.then((inst) => {
setInstances(inst.instances);
set_cur_page(inst.cur_page);
set_total_experiment_inst(inst.total_instances);
set_page_size(inst.page_size);
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
getFrontendFile(exp.experiment_type.id).then((file) => {
addLoadedHtml(exp.experiment_type.id, file);
});
})
.catch(() => {
set_is_loading(false);
});
})
.catch(() => {
notifications.show({
title: "Ошибка",
message:
"Не удалось начать эксперимент, проверьте наличие задач и их правильность",
color: "red",
icon: <IconCancel />,
});
});
}
};
useEffect(() => {
if (profile && !is_loading) {
if (!experiment) {
getExperimentById(Number(experiment_id))
.then((exp) => {
addExperiment(exp);
set_experiment(exp);
getExperimentInstances(exp.id, cur_page, page_size)
.then((inst) => {
setInstances(inst.instances);
set_cur_page(inst.cur_page);
set_total_experiment_inst(inst.total_instances);
set_page_size(inst.page_size);
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
getFrontendFile(exp.experiment_type.id).then((file) => {
addLoadedHtml(exp.experiment_type.id, file);
});
})
.catch(() => {
set_is_loading(false);
});
} else {
if (
(instances.length == 0 && cur_page != 0) ||
(total_experiment_inst > page_size &&
instances.length != page_size &&
cur_page != Math.ceil(total_experiment_inst / page_size))
) {
getExperimentInstances(
experiment.id,
Math.min(cur_page, Math.ceil(total_experiment_inst / page_size)),
page_size,
)
.then((inst) => {
if (inst.instances.length == 0) {
set_cur_page(0);
setInstances([]);
} else {
setInstances(inst.instances);
set_cur_page(inst.cur_page);
set_total_experiment_inst(inst.total_instances);
set_page_size(inst.page_size);
}
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
getFrontendFile(experiment.experiment_type.id).then((file) => {
addLoadedHtml(experiment.experiment_type.id, file);
});
}
}
}
}, [profile, is_loading, instances]);
return ( return (
<> <>
@@ -24,15 +223,24 @@ function ExperimentPage() {
<title> <title>
{experiment {experiment
? "Experiment " + experiment.id + " | QMolSim" ? "Experiment " + experiment.id + " | QMolSim"
: "Error |QmolSim"} : "Error | QMolSim"}
</title> </title>
<meta <meta
name="description" name="description"
content="See the documentation on how to setup and use the qunatum computational system" content="See the documentation on how to setup and use the quantum computational system"
/> />
</Helmet> </Helmet>
{experiment && (
<div className="ExperimentPage"> <div className="ExperimentPage">
{experiment && (
<NewInstanceModal
experiment_id={experiment.id}
isOpened={isOpened}
setIsOpened={setIsOpen}
/>
)}
<LoadingOverlay visible={is_loading_} zIndex={1000} />
{experiment && (
<>
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -42,44 +250,300 @@ function ExperimentPage() {
}} }}
> >
<div className="experimentButtons"> <div className="experimentButtons">
{experiment.status == "DRAFT" && (
<>
<CustomButton
color="accent"
onClick={() => {
handleExperimentStart();
}}
icon={<IconPlus />}
text="Начать эксперимент"
/>
<CustomButton <CustomButton
color="contrast" color="contrast"
onClick={() => { onClick={() => {
addTask(Number(experiment_id) || 0, {
id: 1,
name: "Задача 1",
description: "",
data: {},
});
setIsOpen(true); setIsOpen(true);
}} }}
icon={<IconPlus />} icon={<IconPlus />}
text="Добавить задачу" text="Добавить задачу"
/> />
<CustomButton </>
color="contrast" )}
style="outline" {experiment.status != "DRAFT" && (
<UnstyledButton
onClick={() => { onClick={() => {
setIsOpen(true); getExperimentById(Number(experiment_id))
.then((exp) => {
addExperiment(exp);
set_experiment(exp);
getExperimentInstances(exp.id)
.then((inst) => {
setInstances(inst.instances);
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
getFrontendFile(exp.experiment_type.id).then(
(file) => {
addLoadedHtml(exp.experiment_type.id, file);
},
);
})
.catch(() => {
set_is_loading(false);
});
}} }}
icon={<IconSettings />} >
text="Параметры эксперимента" <IconReload />
</UnstyledButton>
)}
</div>
</div>
{/* Experiment Information Card */}
{/* Experiment Information Card */}
<Card withBorder mb="lg" shadow="sm">
<Card.Section withBorder inheritPadding py="sm">
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</ActionIcon>
<Title order={3}>Информация об эксперименте</Title>
</Group>
{!isEditing ? (
<Button
variant="subtle"
size="sm"
leftSection={<IconEdit size={16} />}
onClick={startEditing}
>
Редактировать
</Button>
) : (
<Group gap="xs">
<Button
size="xs"
variant="filled"
color="green"
leftSection={<IconCheck size={14} />}
onClick={saveExperimentDetails}
>
Сохранить
</Button>
<Button
size="xs"
variant="outline"
color="red"
leftSection={<IconX size={14} />}
onClick={cancelEditing}
>
Отмена
</Button>
</Group>
)}
</Group>
</Card.Section>
<Collapse expanded={isExpanded} style={{ padding: "15px" }}>
<Card.Section inheritPadding py="md">
{!isEditing ? (
<SimpleGrid cols={4} spacing="lg" verticalSpacing="md">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
ID
</Text>
<Text size="md" fw={500}>
{experiment.id}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Тип
</Text>
<Text size="md" fw={500}>
{experiment.experiment_type.name}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Название
</Text>
<Text size="md" fw={500}>
{experiment.name}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Статус
</Text>
<Badge
size="md"
variant="filled"
color={
experiment.status === "DRAFT" ? "yellow" : "green"
}
radius="sm"
>
{experiment.status}
</Badge>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Команда
</Text>
<Text size="md" fw={500}>
{experiment.team.team_name}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Количество задач
</Text>
<Text size="md" fw={500}>
{experiment.instances_count}
</Text>
</div>
<div style={{ gridColumn: "span 2" }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Описание
</Text>
<Text size="md">
{experiment.description || "Нет описания"}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Создан
</Text>
<Text size="sm">
{new Date(experiment.created_at).toLocaleString()}
</Text>
</div>
</SimpleGrid>
) : (
<SimpleGrid cols={4} spacing="lg" verticalSpacing="md">
<div style={{ gridColumn: "span 1" }}>
<Text
size="xs"
c="dimmed"
tt="uppercase"
fw={700}
mb={4}
>
Название
</Text>
<TextInput
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
placeholder="Введите название"
/> />
</div> </div>
</div> <div style={{ gridColumn: "span 3" }}>
{experiment.tasks_ids.length > 0 && ( <Text
<PaginationContainer size="xs"
numberOfPages={1} c="dimmed"
isLoading={false} tt="uppercase"
activePage={1} fw={700}
setPage={() => {}} mb={4}
> >
{experiment.tasks_ids.map((task: number) => { Описание
return <div>{task}</div>; </Text>
})} <TextInput
</PaginationContainer> value={editedDesc}
onChange={(e) => setEditedDesc(e.target.value)}
placeholder="Введите описание"
/>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
ID
</Text>
<Text size="md" fw={500}>
{experiment.id}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Тип
</Text>
<Text size="md" fw={500}>
{experiment.experiment_type.name}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Команда
</Text>
<Text size="md" fw={500}>
{experiment.team.team_name}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Статус
</Text>
<Badge
size="md"
variant="filled"
color={
experiment.status === "DRAFT" ? "yellow" : "green"
}
radius="sm"
>
{experiment.status}
</Badge>
</div>
</SimpleGrid>
)} )}
{experiment.tasks_ids.length == 0 && ( </Card.Section>
</Collapse>
</Card>
{!is_loading_ && (
<PaginationContainer
numberOfPages={Math.ceil(total_experiment_inst / page_size)}
isLoading={false}
activePage={cur_page}
setPage={(page_num) => {
getExperimentInstances(experiment.id, page_num, page_size)
.then((inst) => {
setInstances(inst.instances);
set_cur_page(inst.cur_page);
set_total_experiment_inst(inst.total_instances);
set_page_size(inst.page_size);
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
}}
>
{instances &&
loadedHtmlFiles.get(experiment.experiment_type.id) &&
instances.map((inst: InstanceData) => {
return (
<InstancesListCard
instance={inst}
plugin={
loadedHtmlFiles.get(experiment.experiment_type.id) ||
""
}
key={inst.instance_id}
></InstancesListCard>
);
})}
{instances.length == 0 && !is_loading && (
<Alert> <Alert>
<Center> <Center>
<Text size={"xl"} c="contrast"> <Text size={"xl"} c="contrast">
@@ -88,9 +552,11 @@ function ExperimentPage() {
</Center> </Center>
</Alert> </Alert>
)} )}
</div> </PaginationContainer>
)} )}
{!experiment && ( </>
)}
{!experiment && !is_loading_ && (
<Alert color="red"> <Alert color="red">
<Center> <Center>
{" "} {" "}
@@ -100,6 +566,7 @@ function ExperimentPage() {
</Center> </Center>
</Alert> </Alert>
)} )}
</div>
</> </>
); );
} }

View File

@@ -2,6 +2,8 @@
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
gap: 15px;
} }
.experimentsButtons { .experimentsButtons {

View File

@@ -6,23 +6,39 @@ import { IconMicroscope } from "@tabler/icons-react";
import { NewExperimentModal } from "Modals/NewExperiment/NewExperiment"; import { NewExperimentModal } from "Modals/NewExperiment/NewExperiment";
import ExperimentsListCard from "Components/ListCard/ExperimentsListCard"; import ExperimentsListCard from "Components/ListCard/ExperimentsListCard";
import { useExperimentStore } from "Stores/ExperimentStore"; import { useExperimentStore } from "Stores/ExperimentStore";
import type { Experiment } from "Types/Experiment/Experiment";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { Alert } from "@mantine/core"; import { Alert } from "@mantine/core";
import { getShortTeamsList } from "Api/QuantumBackend/TeamManagement"; import { getShortTeamsList } from "Api/QuantumBackend/TeamManagement";
import { useAuthenticationStore } from "Stores/AuthenticationStore"; import { useAuthenticationStore } from "Stores/AuthenticationStore";
import {
type ExperimentData,
type ExperimentTypeList,
} from "Types/Experiment/Experiment";
import {
getExperimentTypes,
getUserExperiments,
} from "Api/QuantumBackend/ExperimentsManagment";
function ExperimentsPage() { function ExperimentsPage() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { experiments } = useExperimentStore(); const { experiments, setExperiments, setInstances } = useExperimentStore();
const [teams, setTeams] = const [teams, setTeams] =
useState<{ team_id: number; team_name: string }[]>(); useState<{ team_id: number; team_name: string }[]>();
const { profile, is_loading } = useAuthenticationStore(); const { profile, is_loading } = useAuthenticationStore();
const [is_loading_, set_is_loading] = useState(true);
const [exp_types, set_exp_types] = useState<ExperimentTypeList[]>([]);
const [total_experiments, set_total_experiments] = useState<number>(0);
const [page_size, set_page_size] = useState<number>(6);
const [cur_page, set_cur_page] = useState<number>(1);
useEffect(() => { useEffect(() => {
if (profile || !is_loading) if (profile && !is_loading) {
getShortTeamsList() setInstances([]);
getExperimentTypes().then((exp_types) => {
set_exp_types(exp_types);
});
getShortTeamsList({})
.then((teams) => { .then((teams) => {
if (teams) { if (teams) {
setTeams(teams); setTeams(teams);
@@ -31,6 +47,18 @@ function ExperimentsPage() {
.catch(() => { .catch(() => {
setTeams([]); setTeams([]);
}); });
getUserExperiments({ page_num: cur_page, page_size: page_size })
.then((expData) => {
setExperiments(expData.experiments);
set_total_experiments(expData.total_experiments);
set_page_size(expData.page_size);
set_cur_page(expData.cur_page);
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
}
}, [profile, is_loading]); }, [profile, is_loading]);
return ( return (
@@ -46,17 +74,11 @@ function ExperimentsPage() {
isOpened={isOpen} isOpened={isOpen}
setIsOpened={setIsOpen} setIsOpened={setIsOpen}
teams={teams} teams={teams}
types={exp_types.map((type) => {
return { type_id: type.id, type_name: type.name };
})}
/> />
<div className="ExperimentsPage"> <div className="ExperimentsPage">
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
marginBottom: "20px",
}}
>
{teams && teams.length > 0 && (
<div className="experimentsButtons"> <div className="experimentsButtons">
<CustomButton <CustomButton
color="contrast" color="contrast"
@@ -67,30 +89,27 @@ function ExperimentsPage() {
text="Создать эксперимент" text="Создать эксперимент"
/> />
</div> </div>
)} <div className="ExperimentsPage">
</div>
<PaginationContainer <PaginationContainer
numberOfPages={1} numberOfPages={Math.ceil(total_experiments / page_size)}
isLoading={teams == undefined} isLoading={teams == undefined}
activePage={1} activePage={cur_page}
setPage={() => {}} setPage={(page_num) => {
> getUserExperiments({ page_num: page_num, page_size: page_size })
{experiments.map((exp: Experiment) => { .then((expData) => {
return ( setExperiments(expData.experiments);
<ExperimentsListCard set_total_experiments(expData.total_experiments);
experiment={{ set_page_size(expData.page_size);
id: exp.id, set_cur_page(expData.cur_page);
name: exp.name, set_is_loading(false);
description: exp.description, })
team_id: exp.team_id, .catch(() => {
tasks_ids: exp.tasks_ids, set_is_loading(false);
date_created: exp.date_created, });
experiment_status: exp.experiment_status,
experiment_type: "A",
}} }}
team={teams?.find((team) => team.team_id == exp.team_id)} >
/> {experiments.map((exp: ExperimentData) => {
); return <ExperimentsListCard experiment={exp} />;
})} })}
{teams && teams.length == 0 && ( {teams && teams.length == 0 && (
<Alert title="Команды не найдены" color="red"> <Alert title="Команды не найдены" color="red">
@@ -98,13 +117,17 @@ function ExperimentsPage() {
экспериментами экспериментами
</Alert> </Alert>
)} )}
{teams && teams.length != 0 && experiments.length == 0 && ( {teams &&
teams.length != 0 &&
experiments.length == 0 &&
!is_loading_ && (
<Alert title="Экспериментов нету" color="blue"> <Alert title="Экспериментов нету" color="blue">
Вы еще не создали не один эксперимент Вы еще не создали не один эксперимент
</Alert> </Alert>
)} )}
</PaginationContainer> </PaginationContainer>
</div> </div>
</div>
</> </>
); );
} }

View File

@@ -0,0 +1,13 @@
.taskButtons {
display: flex;
flex-direction: row;
justify-content: right;
gap: 15px;
width: 100%;
flex-wrap: nowrap;
text-wrap: nowrap;
* {
max-width: 200px;
}
}

View File

@@ -1,13 +1,100 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { IconCancel, IconPlus, IconSettings } from "@tabler/icons-react"; import { IconCancel, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { IframePlugin } from "Api/PluginLoader/PluginLoader"; import { IframePlugin } from "Api/PluginLoader/PluginLoader";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { InstanceData } from "Types/Experiment/Experiment";
import {
getFrontendFile,
getInstanceById,
updateInstance,
} from "Api/QuantumBackend/ExperimentsManagment";
import { useAuthenticationStore } from "Stores/AuthenticationStore";
import { useExperimentStore } from "Stores/ExperimentStore";
import { SimpleGrid, TextInput, Title } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import "./TaskPage.css";
function TaskPage() { function TaskPage() {
const { task_id } = useParams(); const { task_id } = useParams();
const [data, setData] = useState<{ text: string }>({ text: "" });
const [instance, set_instance] = useState<InstanceData>();
const { loadedHtmlFiles, addLoadedHtml, setInstances } = useExperimentStore();
const { profile, is_loading } = useAuthenticationStore();
const [, set_is_loading] = useState(true);
const [data, setData] = useState<string>();
const [progress, setProgress] = useState<string>();
const [qubits_needed, set_qubits_needed] = useState<number>();
const [name, setName] = useState<string>();
const [descr, setDescr] = useState<string>("");
const [reload, setReload] = useState<number>(0);
const saveData = () => {
if (instance && qubits_needed) {
updateInstance({
instance_id: instance.instance_id,
name: name,
description: descr,
instance_data: JSON.stringify(data),
qubits_needed: qubits_needed,
}).then((updated) => {
set_instance({
instance_id: instance.instance_id,
name: updated.name,
description: updated.description,
instance_data: updated.instance_data,
qubits_needed: updated.qubits_needed,
simulation_result: instance.simulation_result,
});
setName(updated.name);
setDescr(updated.description || "");
setData(updated.instance_data);
});
} else {
if (!qubits_needed) {
notifications.show({
message: "Количество кубит не было определено",
});
}
}
};
const ResetData = () => {
if (instance) {
setData(instance.instance_data);
setName(instance.name);
setDescr(instance.description || "");
setReload(reload + 1);
}
};
useEffect(() => {
if (profile && !is_loading && task_id) {
getInstanceById(Number(task_id))
.then((inst) => {
set_instance(inst);
setData(inst.instance_data);
setProgress(inst.simulation_result?.simulation_result);
setName(inst.name);
setDescr(inst.description || "");
set_qubits_needed(inst.qubits_needed);
getFrontendFile(1).then((file) => {
addLoadedHtml(1, file);
});
set_is_loading(false);
})
.catch(() => {
set_is_loading(false);
});
}
return () => {
setInstances([]);
};
}, [profile, is_loading]);
return ( return (
<> <>
<Helmet> <Helmet>
@@ -29,41 +116,79 @@ function TaskPage() {
marginBottom: "15px", marginBottom: "15px",
}} }}
> >
<div className="experimentButtons"> {(!instance?.simulation_result ||
instance?.simulation_result?.status == "DRAFT") && (
<>
<div className="taskButtons">
<CustomButton <CustomButton
color="accent" color="accent"
onClick={() => {}} onClick={saveData}
disabled={
JSON.stringify(data) ==
JSON.stringify(instance?.instance_data) &&
instance?.name == name &&
(instance?.description || "") == (descr || "")
}
icon={<IconPlus />} icon={<IconPlus />}
text="Сохранить" text="Сохранить"
/> />
<CustomButton <CustomButton
color="red" color="red"
style="outline" style="outline"
onClick={() => {}} onClick={ResetData}
icon={<IconCancel />} icon={<IconCancel />}
text="Отменить" text="Отменить"
/> disabled={
</div> JSON.stringify(data) ==
</div> JSON.stringify(instance?.instance_data) &&
<IframePlugin instance?.name == name &&
pluginUrl={ (instance?.description || "") == (descr || "")
window.location.origin +
"/" +
import.meta.env.VITE_BASE_PATH +
"/" +
"index.html"
} }
mode="editor"
taskData={{
id: Number(task_id),
name: "Молекула 1",
description: "",
data: data,
}}
onUpdate={setData}
/> />
</div> </div>
</> </>
)}
<div>
<SimpleGrid verticalSpacing="lg" cols={2}>
<Title size="lg">Имя:</Title>
<TextInput
size="md"
value={name}
onChange={
(e) => {
setName(e.target.value);
} //set_username(e.currentTarget.value)
}
/>
<Title size="lg">Описание:</Title>
<TextInput
size="md"
value={descr}
onChange={
(e) => {
setDescr(e.target.value);
} //set_email(e.currentTarget.value)
}
/>
</SimpleGrid>
</div>
</div>
{instance && (
<IframePlugin
plugin={loadedHtmlFiles.get(1) || ""}
mode="Editor"
taskData={JSON.stringify(data)}
qubits_needed={qubits_needed || 0}
onUpdate={(data, qubits_need) => {
setData(JSON.parse(data));
set_qubits_needed(qubits_need);
}}
index={instance.instance_id + reload}
simProgress={JSON.stringify(progress)}
/>
)}
</div>
</>
); );
} }

View File

@@ -56,7 +56,7 @@ function TeamPage() {
addTeam(team); addTeam(team);
set_cur_team(team); set_cur_team(team);
set_team_name(team.name); set_team_name(team.name);
set_team_descr(team.description); set_team_descr(team.description || "");
set_is_loading(false); set_is_loading(false);
} }
}); });
@@ -66,7 +66,7 @@ function TeamPage() {
profile && profile &&
!is_loading !is_loading
) { ) {
getTeamSystems(Number(team_id), cur_page, 9 ) getTeamSystems(Number(team_id), cur_page, 9)
.then((systems) => { .then((systems) => {
if (systems) { if (systems) {
set_machines(systems); set_machines(systems);
@@ -210,8 +210,8 @@ function TeamPage() {
cur_team?.description == team_descr cur_team?.description == team_descr
} }
onClick={() => { onClick={() => {
set_team_name(cur_team?.name); set_team_name(cur_team?.name || "");
set_team_descr(cur_team?.description); set_team_descr(cur_team?.description || "");
}} }}
/> />
</Grid.Col> </Grid.Col>
@@ -252,7 +252,7 @@ function TeamPage() {
})} })}
</div> </div>
)} )}
{!cur_team && !is_loading && ( {!cur_team && !is_loading_this && (
<Alert color="red"> <Alert color="red">
<Center> <Center>
{" "} {" "}

View File

@@ -7,6 +7,12 @@ import {
Switch, Switch,
LoadingOverlay, LoadingOverlay,
Space, Space,
Avatar,
Center,
Grid,
FileInput,
Group,
rem,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconChartCandle, IconChartCandle,
@@ -15,6 +21,7 @@ import {
IconCancel, IconCancel,
IconSun, IconSun,
IconMoonStars, IconMoonStars,
IconUpload,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import keycloak, { import keycloak, {
SendEmailVerification, SendEmailVerification,
@@ -29,19 +36,22 @@ import { useUserPreferencesStore } from "Stores/PreferencesStore";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { import { UpdateCurrentUserInfo } from "Api/QuantumBackend/UserManagement";
GetCurrentUserInfo,
UpdateCurrentUserInfo,
} from "Api/QuantumBackend/UserManagement";
import type { UserData } from "Types/User/User";
function UserPage() { function UserPage() {
const { profile, is_loading, profile_picture_path } = const {
useAuthenticationStore(); profile,
is_loading,
profile_picture_path,
set_profile_picture_path,
} = useAuthenticationStore();
const { theme, set_theme } = useUserPreferencesStore(); const { theme, set_theme } = useUserPreferencesStore();
const [username, set_username] = useState<string>(""); const [username, set_username] = useState<string>("");
const [email, set_email] = useState<string>(""); const [email, set_email] = useState<string>("");
const [pfp_path, set_pfp_path] = useState<string>("");
const [is_editing_path, set_is_editing_path] = useState<boolean>(false); const [is_editing_path, set_is_editing_path] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const updateData = () => { const updateData = () => {
@@ -53,9 +63,7 @@ function UserPage() {
const prof_1 = profile; const prof_1 = profile;
prof_1.email = email; prof_1.email = email;
prof_1.username = username; prof_1.username = username;
useAuthenticationStore.setState({
is_loading: true,
});
updateUserData(prof_1).then((data) => { updateUserData(prof_1).then((data) => {
if (data) { if (data) {
notifications.show({ notifications.show({
@@ -76,15 +84,81 @@ function UserPage() {
} }
}; };
const handleFileChange = (file: File | null) => {
setSelectedFile(file);
if (file) {
// Create preview
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
}
};
const update_pfp = async () => { const update_pfp = async () => {
UpdateCurrentUserInfo(pfp_path).then(() => { if (!selectedFile) return;
GetCurrentUserInfo().then((info: UserData | undefined) => {
if (info && info.profile_picture_path) setIsUploading(true);
useAuthenticationStore.setState({ try {
profile_picture_path: info.profile_picture_path, const result = await UpdateCurrentUserInfo(selectedFile);
}); if (result && result.profile_picture_path) {
try {
const response = await fetch(
`${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user/serve/${profile?.id}`,
{
headers: {
Authorization: `Bearer ${keycloak.token}`,
},
},
);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
set_profile_picture_path(url);
}
} catch (error) {
console.error("Failed to load avatar:", error);
}
notifications.show({
radius: "md",
title: "Фотография профиля обновлена",
message: "",
icon: <IconCheck />,
color: "green",
}); });
set_is_editing_path(false);
// Clean up
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
setSelectedFile(null);
}
} catch {
notifications.show({
radius: "md",
title: "Ошибка",
message: "Не удалось обновить фотографию профиля",
icon: <IconCancel />,
color: "red",
}); });
} finally {
setIsUploading(false);
set_is_editing_path(false);
}
};
const cancelUpload = () => {
set_is_editing_path(false);
setSelectedFile(null);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
}; };
useEffect(() => { useEffect(() => {
@@ -103,10 +177,6 @@ function UserPage() {
} }
}, [profile, searchParams]); }, [profile, searchParams]);
useEffect(() => {
if (profile_picture_path) set_pfp_path(profile_picture_path);
}, [profile_picture_path]);
return ( return (
<> <>
<Helmet> <Helmet>
@@ -153,20 +223,70 @@ function UserPage() {
overlayProps={{ radius: "sm", blur: 2 }} overlayProps={{ radius: "sm", blur: 2 }}
loaderProps={{ size: 50, type: "dots" }} loaderProps={{ size: 50, type: "dots" }}
/> />
<SimpleGrid verticalSpacing="lg" cols={2}> <Grid>
<Grid.Col span={4} style={{ height: "100%" }}>
<Center>
<div style={{ display: "flex", flexDirection: "column" }}>
<Title size="xl">Фотография профиля</Title>
</div>
</Center>
<Center style={{ padding: "15px" }}>
<Avatar size="xl" src={profile_picture_path} radius="xl" />
{is_editing_path && (
<FileInput
placeholder="Выберите изображение"
accept="image/png,image/jpeg,image/jpg,image/webp"
value={selectedFile}
onChange={handleFileChange}
leftSection={<IconUpload size={rem(14)} />}
clearable
style={{ width: "100%" }}
disabled={isUploading}
/>
)}
</Center>
<div>
{!is_editing_path ? (
<CustomButton
style="outline"
color="contrast"
text="Изменить"
onClick={() => {
set_is_editing_path(true);
}}
/>
) : (
<Group justify="center" mt="md">
<CustomButton
style="outline"
color="red"
text="Отменить"
onClick={cancelUpload}
disabled={isUploading}
/>
<CustomButton
style="color"
color="accent"
text="Сохранить"
onClick={update_pfp}
disabled={!selectedFile}
/>
</Group>
)}
</div>
</Grid.Col>
<Grid.Col
span={4}
style={{
flexGrow: 1,
justifyContent: "space-between",
display: "flex",
flexDirection: "column",
}}
>
<Title size="lg">Имя пользователя:</Title> <Title size="lg">Имя пользователя:</Title>
<TextInput
size="md"
value={username}
onChange={(e) => set_username(e.currentTarget.value)}
/>
<Title size="lg">Почта:</Title>
<TextInput
size="md"
value={email}
onChange={(e) => set_email(e.currentTarget.value)}
/>
<Title size="lg">Почта:</Title>
<CustomButton <CustomButton
color="accent" color="accent"
text="Сохранить" text="Сохранить"
@@ -176,10 +296,32 @@ function UserPage() {
profile != null && profile != null &&
((profile.username != undefined && ((profile.username != undefined &&
profile.username != username) || profile.username != username) ||
(profile.email != undefined && profile.email != email)) (profile.email != undefined &&
profile.email != email))
) )
} }
/> />
</Grid.Col>
<Grid.Col
span={4}
style={{
flexGrow: 1,
justifyContent: "space-between",
display: "flex",
flexDirection: "column",
}}
>
<TextInput
size="md"
value={username}
onChange={(e) => set_username(e.currentTarget.value)}
/>
<TextInput
size="md"
value={email}
onChange={(e) => set_email(e.currentTarget.value)}
/>
<CustomButton <CustomButton
color="error" color="error"
text="Отменить" text="Отменить"
@@ -194,11 +336,13 @@ function UserPage() {
profile != null && profile != null &&
((profile.username != undefined && ((profile.username != undefined &&
profile.username != username) || profile.username != username) ||
(profile.email != undefined && profile.email != email)) (profile.email != undefined &&
profile.email != email))
) )
} }
/> />
</SimpleGrid> </Grid.Col>
</Grid>
<Divider my="lg" /> <Divider my="lg" />
<SimpleGrid verticalSpacing={"lg"} cols={2}> <SimpleGrid verticalSpacing={"lg"} cols={2}>
<Title <Title
@@ -266,60 +410,12 @@ function UserPage() {
/> />
</div> </div>
</SimpleGrid> </SimpleGrid>
<Divider my="lg" />
<div style={{ display: "flex", flexDirection: "column" }}>
<Title size="lg">Фотография профиля</Title>
<Space my="sm" />
{!is_editing_path ? (
<img height={350} width={350} src={profile_picture_path} />
) : (
<TextInput
size="md"
value={pfp_path}
onChange={(e) => set_pfp_path(e.currentTarget.value)}
/>
)}
<Space my="sm" />
<div style={{ width: "15em" }}>
{!is_editing_path ? (
<CustomButton
style="outline"
color="contrast"
text="Изменить"
onClick={() => {
set_is_editing_path(true);
}}
/>
) : (
<>
<CustomButton
style="outline"
color="contrast"
text="Сохранить"
onClick={() => {
update_pfp();
set_is_editing_path(false);
}}
/>
<Space my="sm" />
<CustomButton
style="outline"
color="red"
text="Отменить"
onClick={() => {
set_is_editing_path(false);
set_pfp_path(profile_picture_path);
}}
/>
</>
)}
</div>
</div>
</div> </div>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="preference"> <Tabs.Panel value="preference">
<Title size={"lg"}>Тема приложения:</Title>
<Space h="md" />
<Switch <Switch
size="xl" size="xl"
defaultChecked={theme == "light"} defaultChecked={theme == "light"}

View File

@@ -1,6 +1,6 @@
import { Breadcrumbs } from "@mantine/core"; import { Breadcrumbs } from "@mantine/core";
import { type ReactElement } from "react"; import { type ReactElement } from "react";
import { Link } from "react-router"; import { useNavigate } from "react-router";
import "./Breadcrumbs.css"; import "./Breadcrumbs.css";
import { routes } from "Routes/Routes"; import { routes } from "Routes/Routes";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
@@ -46,6 +46,7 @@ function testEqual(path: string, pattern: string) {
} }
function BreadCrumbs() { function BreadCrumbs() {
const navigate = useNavigate();
const unique_matches: string[] = getSubPaths(useLocation().pathname); const unique_matches: string[] = getSubPaths(useLocation().pathname);
//find the breadcrumbs for the matched pathes //find the breadcrumbs for the matched pathes
@@ -56,13 +57,15 @@ function BreadCrumbs() {
for (const i in routes[prop].breadcrumbs(unique_matches[u_match])) { for (const i in routes[prop].breadcrumbs(unique_matches[u_match])) {
if (elements.length + 1 != unique_matches.length) { if (elements.length + 1 != unique_matches.length) {
elements.push( elements.push(
<Link <div
className="invisible_link" className="invisible_link"
to={unique_matches[u_match]}
key={unique_matches[u_match]} key={unique_matches[u_match]}
onClick={() => {
navigate(unique_matches[u_match]);
}}
> >
{routes[prop].breadcrumbs(unique_matches[u_match])[i]} {routes[prop].breadcrumbs(unique_matches[u_match])[i]}
</Link>, </div>,
); );
} else { } else {
elements.push( elements.push(

View File

@@ -34,7 +34,7 @@ export const routes: {
], ],
}, },
TaskPage: { TaskPage: {
path: "/experiments/:experiment_id/:molecule_id", path: "/experiments/:experiment_id/:task_id",
breadcrumbs: (path: string) => [ breadcrumbs: (path: string) => [
<>Молекула #{path.split("/")[path.split("/").length - 1]}</>, <>Молекула #{path.split("/")[path.split("/").length - 1]}</>,
], ],
@@ -62,8 +62,7 @@ export const routes: {
SettingsPage: { path: "/settings", breadcrumbs: () => [<>Настройки</>] }, SettingsPage: { path: "/settings", breadcrumbs: () => [<>Настройки</>] },
}; };
const router = createBrowserRouter( const router = createBrowserRouter([
[
{ {
path: "/", path: "/",
element: <App />, element: <App />,
@@ -121,7 +120,5 @@ const router = createBrowserRouter(
}, },
], ],
}, },
], ]);
{ basename: import.meta.env.VITE_BASE_PATH },
);
export default router; export default router;

View File

@@ -1,30 +1,70 @@
import { create } from "zustand"; import { create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
import type { Experiment, TaskData } from "Types/Experiment/Experiment"; import type {
ExperimentData,
ExperimentTypeList,
InstanceData,
} from "Types/Experiment/Experiment";
import { enableMapSet } from "immer";
// Call this once at your app's entry point (before using Immer)
enableMapSet();
interface ExperimentStoreState { interface ExperimentStoreState {
experiments: Experiment[]; experiments: ExperimentData[];
tasks: TaskData[]; instances: InstanceData[];
experimentTypes: ExperimentTypeList[] | null;
loadedHtmlFiles: Map<number, string>; // experiment_type_id -> HTML content
addExperiment: (experiment: Experiment) => void; setInstances: (instances: InstanceData[]) => void;
updateExperiment: (id: number, data: Partial<Experiment>) => void; updateInstance: (id: number, data: Partial<InstanceData>) => void;
removeInstance: (id: number) => void;
setExperiments: (experiment: ExperimentData[]) => void;
addExperiment: (experiment: ExperimentData) => void;
updateExperiment: (id: number, data: Partial<ExperimentData>) => void;
removeExperiment: (id: number) => void; removeExperiment: (id: number) => void;
addTask: (experimentId: number, task: TaskData) => void; // Only what you asked for:
updateTask: (taskId: number, data: Partial<TaskData>) => void; setExperimentTypes: (types: ExperimentTypeList[]) => void;
removeTask: (experimentId: number, taskId: number) => void; addLoadedHtml: (typeId: number, htmlContent: string) => void;
} }
export const useExperimentStore = create<ExperimentStoreState>()( export const useExperimentStore = create<ExperimentStoreState>()(
immer((set) => ({ immer((set) => ({
experiments: [], experiments: [],
tasks: [],
teams: [], teams: [],
instances: [],
experimentTypes: null,
loadedHtmlFiles: new Map(),
setExperiments: (experiments) =>
set((state) => {
state.experiments = experiments;
}),
addExperiment: (experiment) => addExperiment: (experiment) =>
set((state) => { set((state) => {
state.experiments.push(experiment); state.experiments = [experiment, ...state.experiments];
}),
setInstances: (instances) =>
set((state) => {
state.instances = instances;
}),
updateInstance: (id, data) =>
set((state) => {
const exp = state.instances.find((e) => e.instance_id === id);
if (!exp) return;
Object.assign(exp, data);
}),
removeInstance: (id) =>
set((state) => {
state.instances = state.instances.filter((e) => e.instance_id !== id);
}), }),
updateExperiment: (id, data) => updateExperiment: (id, data) =>
@@ -39,31 +79,15 @@ export const useExperimentStore = create<ExperimentStoreState>()(
set((state) => { set((state) => {
state.experiments = state.experiments.filter((e) => e.id !== id); state.experiments = state.experiments.filter((e) => e.id !== id);
}), }),
// Only these two new methods
addTask: (experimentId, task) => setExperimentTypes: (types) =>
set((state) => { set((state) => {
const exp = state.experiments.find((e) => e.id === experimentId); state.experimentTypes = types;
if (!exp) return;
exp.tasks_ids.push(task.id);
state.tasks.push(task);
}), }),
updateTask: (taskId, data) => addLoadedHtml: (typeId, htmlContent) =>
set((state) => { set((state) => {
const task = state.tasks.find((t) => t.id === taskId); state.loadedHtmlFiles.set(typeId, htmlContent);
if (!task) return;
Object.assign(task, data);
}),
removeTask: (experimentId, taskId) =>
set((state) => {
const exp = state.experiments.find((e) => e.id === experimentId);
if (!exp) return;
state.tasks = state.tasks.filter((t) => t.data.id !== taskId);
exp.tasks_ids = exp.tasks_ids.filter((t) => t !== taskId);
}), }),
})), })),
); );

View File

@@ -1,7 +0,0 @@
export interface ConvertSchema {
inputText: string;
inputFormat: string;
add_h: boolean;
make_3d: boolean;
optimize: boolean;
}

View File

@@ -1,40 +1,101 @@
export type ExperimentStatus = export interface ExperimentTypeList {
| "DRAFT"
| "QUEUE"
| "PROCESSING"
| "SUCCESS"
| "ERROR";
export interface Experiment {
id: number; id: number;
name: string; name: string;
description: string; description?: string;
}
export interface CreateExperimentTypeResponse {
id: number;
name: string;
description?: string;
}
export interface CreateExperimentRequest {
team_id: number; team_id: number;
date_created: Date; experiment_type_id: number;
experiment_status: ExperimentStatus;
experiment_type: string;
tasks_ids: number[];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface TaskTypePlugin<TData = any> {
type: string;
// how it appears in the experiment task list
ListItem: React.ComponentType<TaskData<TData>>;
// full editor UI when clicking task
Editor: React.ComponentType<TaskEditorProps<TData>>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface TaskData<TData = any> {
id: number;
name: string; name: string;
description: string; description?: string;
data: TData;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface UpdateExperimentRequest {
export interface TaskEditorProps<TData = any> { experiment_id: number;
data: TData; name?: string;
setData: (data: TData) => void; description?: string;
}
export interface ExperimentData {
id: number;
team: {
team_id: number;
team_name: string;
};
name: string;
description?: string;
created_at: string;
experiment_type: ExperimentTypeList;
instances_count: number;
instance_preview: SimpleInstanceData[];
status: string;
}
export interface ExperimentListResponse {
experiments: ExperimentData[];
cur_page: number;
total_experiments: number;
page_size: number;
}
export interface CreateInstanceRequest {
experiment_id: number;
instance_data: string;
name: string;
description?: string;
}
export interface UpdateInstanceRequest {
instance_id: number;
name?: string;
description?: string;
instance_data?: string;
qubits_needed: number;
}
export interface SimpleInstanceData {
id: number;
instance_data: string;
name: string;
description?: string;
qubits_needed: number;
}
export interface SimulationResultData {
id: number;
comp_system: {
system_id: number;
system_name: string;
};
simulation_result: string;
status: string;
started_at?: string;
ended_at?: string;
}
export interface InstanceData {
instance_id: number;
instance_data: string;
name: string;
description?: string;
simulation_result?: SimulationResultData;
qubits_needed: number;
}
export interface InstanceListResponse {
instances: InstanceData[];
cur_page: number;
total_instances: number;
page_size: number;
}
export interface StartExperimentRequest {
experiment_id: number;
} }

View File

@@ -15,7 +15,7 @@ import { useAuthenticationStore } from "Stores/AuthenticationStore";
import { useUserPreferencesStore } from "Stores/PreferencesStore"; import { useUserPreferencesStore } from "Stores/PreferencesStore";
import type { KeycloakProfile } from "keycloak-js"; import type { KeycloakProfile } from "keycloak-js";
import { GetCurrentUserInfo } from "Api/QuantumBackend/UserManagement"; import { GetCurrentUserInfo } from "Api/QuantumBackend/UserManagement";
import type { UserData } from "Types/User/User";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconForbid } from "@tabler/icons-react"; import { IconForbid } from "@tabler/icons-react";
@@ -71,11 +71,8 @@ async function bootstrap() {
onLoad: "check-sso", onLoad: "check-sso",
pkceMethod: "S256", pkceMethod: "S256",
silentCheckSsoRedirectUri: silentCheckSsoRedirectUri:
window.location.origin + window.location.origin + "/silent-check-sso.html",
"/" + silentCheckSsoFallback: true,
import.meta.env.VITE_BASE_PATH +
"/silent-check-sso.html",
silentCheckSsoFallback: false,
}) })
.then((authenticated: boolean) => { .then((authenticated: boolean) => {
if (authenticated) { if (authenticated) {
@@ -85,12 +82,7 @@ async function bootstrap() {
profile: profile, profile: profile,
}); });
GetCurrentUserInfo() GetCurrentUserInfo()
.then((info: UserData | undefined) => { .then()
if (info && info.profile_picture_path)
useAuthenticationStore.setState({
profile_picture_path: info.profile_picture_path,
});
})
.catch(() => { .catch(() => {
notifications.show({ notifications.show({
radius: "md", radius: "md",

View File

@@ -14,7 +14,7 @@ export default defineConfig({
preview: { preview: {
port: Number(process.env.VITE_PORT), port: Number(process.env.VITE_PORT),
}, },
base: "/" + process.env.VITE_BASE_PATH,
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {