diff --git a/.gitignore b/.gitignore index b7a121b8b..77fa56bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -164,4 +164,4 @@ runner.yml .workspace # command scripts for runner -servers/execution/runner/lifecycle* \ No newline at end of file +servers/execution/runner/lifecycle* diff --git a/client/DEVELOPER.md b/client/DEVELOPER.md index 84ae825ee..9be66ce91 100644 --- a/client/DEVELOPER.md +++ b/client/DEVELOPER.md @@ -111,6 +111,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', @@ -140,6 +141,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', @@ -178,3 +180,24 @@ port and basename options in the docker-based development environment. Each new release of client web application is published as a docker container image. Please see [publishing](../docker/README.md) page for more information publishing docker images. + +## Gitlab Integration + +The client codebase has been using Gitlab for OAuth2 only. There is +an ongoing effort to integrate Gitlab CI/CD capabilities to automate +the execution of Digital Twins. This code is in alpha stage and is +available in `src/util/gitlab*.ts`. +This code can be developed and tested using the following yarn commands. + +```bash +yarn gitlab:compile +yarn gitlab:run +``` + +## Digital Twins page preview + +In the Workbench section, there is a link to preview the **Digital Twins** +page. The GitLab account used as OAuth provider must have a *DTaaS* group, +a project under your username, and a *digital_twins* folder which contains +the Digital Twins. From this interface, you can start or stop execution of +Digital Twins, and once the execution is complete, view the complete logs. \ No newline at end of file diff --git a/client/README.md b/client/README.md index 9d30beddf..ba39b4227 100644 --- a/client/README.md +++ b/client/README.md @@ -97,3 +97,38 @@ This error is expected. If you would like to try the complete DTaaS application, please see localhost installation in [docs](https://into-cps-association.github.io/DTaaS/development/admin/localhost.html). + +## Gitlab Runner configuration + +To properly use the Digital Twins page preview, you need to configure at least +one project runner in your GitLab profile. Follow the steps below: + +1. Login to the GitLab profile that will be used as the OAuth provider. + +1. Navigate to the *DTaaS* group and select the project named after your + GitLab username. + +1. In the project menu, go to Settings and select CI/CD. + +1. Expand the **Runners** section and click on *New project runner*. Follow the + configuration instructions carefully: + - Add **linux** as a tag during configuration. + - Click on *Create runner*. + - Ensure GitLab Runner is installed before proceeding. Depending on your + environment, you will be shown the correct command to install GitLab Runner. + - Once GitLab Runner is installed, follow these steps to register the runner: + - Copy and paste the command shown in the GitLab interface into your command + line to register the runner. It includes a URL and a token for your specific + GitLab instance. + - Choose *docker* as executor when prompted by the command line. + - Choose the default docker image. You must use an image based on Linux, + like the default one (*ruby:2.7*). + +You can manually verify that the runner is available to pick up jobs by running +the following command: + +```bash +sudo gitlab-runner run +``` + +It can also be used to reactivate offline runners during subsequent sessions. \ No newline at end of file diff --git a/client/config/dev.js b/client/config/dev.js index 306125768..e24e01480 100644 --- a/client/config/dev.js +++ b/client/config/dev.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/client/config/gitlab.json b/client/config/gitlab.json new file mode 100644 index 000000000..8bf841ea1 --- /dev/null +++ b/client/config/gitlab.json @@ -0,0 +1,5 @@ +{ + "username": "user1", + "host": "https://maestro.cps.digit.au.dk/gitlab", + "oauth_token": "" +} diff --git a/client/config/local.js b/client/config/local.js index 9a9fc669b..ebf935f90 100644 --- a/client/config/local.js +++ b/client/config/local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', @@ -16,4 +17,4 @@ if (typeof window !== 'undefined') { REACT_APP_LOGOUT_REDIRECT_URI: 'http://localhost/', REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', }; -}; +}; \ No newline at end of file diff --git a/client/config/prod.js b/client/config/prod.js index bda37f0bf..8b54ea9c3 100644 --- a/client/config/prod.js +++ b/client/config/prod.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', diff --git a/client/config/test.js b/client/config/test.js index 5d4dfbd71..2c6ab3b2e 100644 --- a/client/config/test.js +++ b/client/config/test.js @@ -9,9 +9,10 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', - REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', - REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', + REACT_APP_CLIENT_ID: '38bf4764fad5ebb2ebbf49b4f57c7720145b61266f13bf4891ff7851dd5c6563', + REACT_APP_AUTH_AUTHORITY: 'https://maestro.cps.digit.au.dk/gitlab', REACT_APP_REDIRECT_URI: 'http://localhost:4000/Library', REACT_APP_LOGOUT_REDIRECT_URI: 'http://localhost:4000/', REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', diff --git a/client/env.d.ts b/client/env.d.ts index 0a5485157..6aad96ef7 100644 --- a/client/env.d.ts +++ b/client/env.d.ts @@ -12,6 +12,7 @@ declare global { REACT_APP_WORKBENCHLINK_VSCODE: string; REACT_APP_WORKBENCHLINK_JUPYTERLAB: string; REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: string; + REACT_APP_WORKBENCHLINK_DT_PREVIEW: string; REACT_APP_CLIENT_ID: string; REACT_APP_AUTH_AUTHORITY: string; diff --git a/client/package.json b/client/package.json index a37e19d81..5d8332ebc 100644 --- a/client/package.json +++ b/client/package.json @@ -16,13 +16,15 @@ "type": "module", "scripts": { "build": "npx react-scripts build", - "clean": "npx rimraf build/ node_modules/ coverage/ playwright-report/ *.svg", + "clean": "npx rimraf build/ dist/ node_modules/ coverage/ playwright-report/ *.svg", "config:dev": "npx shx cp config/dev.js public/env.js && npx shx cp config/dev.js build/env.js", "config:local": "npx shx cp config/local.js public/env.js && npx shx cp config/local.js build/env.js", "config:prod": "npx shx cp config/prod.js public/env.js && npx shx cp config/prod.js build/env.js", "config:test": "npx shx cp config/test.js public/env.js && npx shx cp config/test.js build/env.js", "develop": "npx react-scripts start", "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", + "gitlab:compile": "npx tsc --project tsconfig.gitlab.json", + "gitlab:run": "node dist/gitlabDriver.js", "graph": "npx madge --image src.svg src && npx madge --image test.svg test", "start": "serve -s build -l 4000", "stop": "npx kill-port 4000", @@ -44,12 +46,15 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@fontsource/roboto": "^5.0.8", + "@gitbeaker/rest": "^40.1.3", "@mui/icons-material": "^5.14.8", "@mui/material": "^5.14.8", "@reduxjs/toolkit": "^1.9.7", + "@types/strip-ansi": "^5.2.1", "@types/styled-components": "^5.1.32", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.12.0", + "ansi-regex": "^6.0.1", "dotenv": "^16.1.4", "eslint": "8.54.0", "eslint-config-airbnb-base": "^15.0.0", @@ -72,6 +77,7 @@ "redux": "^4.2.1", "resize-observer-polyfill": "^1.5.1", "serve": "^14.2.1", + "strip-ansi": "^7.1.0", "styled-components": "^6.1.1", "typescript": "^4.9.5" }, @@ -106,4 +112,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/client/src/components/LinkIconsLib.tsx b/client/src/components/LinkIconsLib.tsx index 9af182044..46c2efcf0 100644 --- a/client/src/components/LinkIconsLib.tsx +++ b/client/src/components/LinkIconsLib.tsx @@ -6,6 +6,7 @@ import NoteAltOutlinedIcon from '@mui/icons-material/NoteAltOutlined'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import GitHubIcon from '@mui/icons-material/GitHub'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import TabIcon from '@mui/icons-material/Tab'; type LinkIconsType = { [key: string]: { icon: React.ReactElement; name: string | undefined }; @@ -28,6 +29,10 @@ const LinkIcons: LinkIconsType = { icon: , name: 'Jupyter Notebook', }, + DT_PREVIEW: { + icon: , + name: 'Digital Twins page preview', + }, GITHUB: { icon: , name: 'ToolbarIcon', diff --git a/client/src/components/asset/Asset.ts b/client/src/components/asset/Asset.ts new file mode 100644 index 000000000..70a8e8717 --- /dev/null +++ b/client/src/components/asset/Asset.ts @@ -0,0 +1,5 @@ +export interface Asset { + name: string; + description?: string; + path: string; +} diff --git a/client/src/components/asset/AssetBoard.tsx b/client/src/components/asset/AssetBoard.tsx new file mode 100644 index 000000000..c8004dfc7 --- /dev/null +++ b/client/src/components/asset/AssetBoard.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Grid } from '@mui/material'; +import { GitlabInstance } from 'util/gitlab'; +import { Asset } from './Asset'; +import AssetCardExecute from './AssetCard'; + +const outerGridContainerProps = { + container: true, + spacing: 2, + sx: { + justifyContent: 'flex-start', + overflow: 'auto', + maxHeight: 'inherent', + marginTop: 2, + }, +}; + +/** + * Displays a board with navigational properties to locate and select assets for DT configuration. + * @param props Takes relative path to Assets. E.g `Functions` for function assets. + * @returns + */ +function AssetBoard(props: { + subfolders: Asset[]; + gitlabInstance: GitlabInstance; + error: string | null; +}) { + if (props.error) { + return {props.error}; + } + + return ( + + {props.subfolders.map((asset) => ( + + + + ))} + + ); +} + +export default AssetBoard; diff --git a/client/src/components/asset/AssetCard.tsx b/client/src/components/asset/AssetCard.tsx new file mode 100644 index 000000000..6ce899eb3 --- /dev/null +++ b/client/src/components/asset/AssetCard.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import { AlertColor, CardActions, Grid } from '@mui/material'; +import styled from '@emotion/styled'; +import DigitalTwin from 'util/gitlabDigitalTwin'; +import { GitlabInstance } from 'util/gitlab'; +import { getAuthority } from 'util/envUtil'; +import CustomSnackbar from 'route/digitaltwins/Snackbar'; +import { useDispatch } from 'react-redux'; +import { setDigitalTwin } from 'store/digitalTwin.slice'; +import LogDialog from 'route/digitaltwins/LogDialog'; +import StartStopButton from './StartStopButton'; +import LogButton from './LogButton'; +import { Asset } from './Asset'; + +interface AssetCardProps { + asset: Asset; + buttons?: React.ReactNode; +} + +interface AssetCardExecuteProps { + asset: Asset; +} + +interface CardButtonsContainerExecuteProps { + assetName: string; + setSnackbarOpen: Dispatch>; + setSnackbarMessage: Dispatch>; + setSnackbarSeverity: Dispatch>; + executionCount: number; + setExecutionCount: Dispatch>; + setJobLogs: Dispatch>; + setShowLog: Dispatch>; +} + +const Header = styled(Typography)` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + white-space. nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Description = styled(Typography)` + display: -webkit-box; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +`; + +const formatName = (name: string) => + name.replace(/-/g, ' ').replace(/^./, (char) => char.toUpperCase()); + +function CardActionAreaContainer(asset: Asset) { + return ( + + + + + {asset.description} + + + + + ); +} + +function CardButtonsContainerExecute({ + assetName, + setSnackbarOpen, + setSnackbarMessage, + setSnackbarSeverity, + executionCount, + setExecutionCount, + setJobLogs, + setShowLog, +}: CardButtonsContainerExecuteProps) { + const [pipelineCompleted, setPipelineCompleted] = useState(false); + + return ( + + + + + ); +} + +function AssetCard({ asset, buttons }: AssetCardProps) { + return ( + + {formatName(asset.name)} + + {buttons} + + ); +} + +function AssetCardExecute({ asset }: AssetCardExecuteProps) { + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const [snackbarSeverity, setSnackbarSeverity] = + useState('success'); + const [showLog, setShowLog] = useState(false); + const [executionCount, setExecutionCount] = useState(0); + const [jobLogs, setJobLogs] = useState<{ jobName: string; log: string }[]>( + [], + ); + + const dispatch = useDispatch(); + + useEffect(() => { + const gitlabInstance = new GitlabInstance( + sessionStorage.getItem('username') || '', + getAuthority(), + sessionStorage.getItem('access_token') || '', + ); + gitlabInstance.init(); + dispatch( + setDigitalTwin({ + assetName: asset.name, + digitalTwin: new DigitalTwin(asset.name, gitlabInstance), + }), + ); + }, []); + + return ( + <> + + } + /> + + + > + ); +} + +export default AssetCardExecute; diff --git a/client/src/components/asset/LogButton.tsx b/client/src/components/asset/LogButton.tsx new file mode 100644 index 000000000..5150f2e0f --- /dev/null +++ b/client/src/components/asset/LogButton.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { Button } from '@mui/material'; + +interface LogButtonProps { + pipelineCompleted: boolean; + setShowLog: Dispatch>; +} + +const handleToggleLog = (setShowLog: Dispatch>) => { + setShowLog((prev) => !prev); +}; + +function LogButton({ pipelineCompleted, setShowLog }: LogButtonProps) { + return ( + handleToggleLog(setShowLog)} + disabled={!pipelineCompleted} + > + Log + + ); +} + +export default LogButton; diff --git a/client/src/components/asset/StartStopButton.tsx b/client/src/components/asset/StartStopButton.tsx new file mode 100644 index 000000000..420dc3a54 --- /dev/null +++ b/client/src/components/asset/StartStopButton.tsx @@ -0,0 +1,83 @@ +import React, { useState, Dispatch, SetStateAction, useEffect } from 'react'; +import { AlertColor, Button, CircularProgress } from '@mui/material'; +import { handleButtonClick } from 'route/digitaltwins/ExecutionFunctions'; +import { useSelector } from 'react-redux'; +import { RootState } from 'store/store'; + +export interface JobLog { + jobName: string; + log: string; +} + +interface StartStopButtonProps { + assetName: string; + setSnackbarOpen: Dispatch>; + setSnackbarMessage: Dispatch>; + setSnackbarSeverity: Dispatch>; + executionCount: number; + setExecutionCount: Dispatch>; + setJobLogs: Dispatch>; + setPipelineCompleted: Dispatch>; +} + +function StartStopButton({ + assetName, + setSnackbarOpen, + setSnackbarMessage, + setSnackbarSeverity, + executionCount, + setExecutionCount, + setJobLogs, + setPipelineCompleted, +}: StartStopButtonProps) { + const [executionStatus, setExecutionStatus] = useState(null); + const [pipelineLoading, setPipelineLoading] = useState(false); + const [buttonText, setButtonText] = useState('Start'); + + const digitalTwin = useSelector( + (state: RootState) => state.digitalTwin[assetName], + ); + + useEffect(() => { + if (executionStatus) { + setSnackbarMessage( + `Execution ${executionStatus} for ${digitalTwin.DTName} (Run #${executionCount})`, + ); + setSnackbarSeverity(executionStatus === 'success' ? 'success' : 'error'); + setSnackbarOpen(true); + } + }, [executionStatus, executionCount]); + + return ( + <> + {pipelineLoading ? ( + + ) : null}{' '} + + handleButtonClick( + buttonText, + setButtonText, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setExecutionStatus, + setExecutionCount, + digitalTwin, + setSnackbarMessage, + setSnackbarSeverity, + setSnackbarOpen, + executionCount, + ) + } + > + {buttonText} + + > + ); +} + +export default StartStopButton; diff --git a/client/src/route/digitaltwins/DigitalTwinCard.tsx b/client/src/route/digitaltwins/DigitalTwinCard.tsx new file mode 100644 index 000000000..8a040e827 --- /dev/null +++ b/client/src/route/digitaltwins/DigitalTwinCard.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Card, CardContent, Typography } from '@mui/material'; + +const DigitalTwinCard: React.FC<{ + name: string; + description: string; + buttons: React.ReactNode; +}> = (props) => ( + + + + {props.name} + + + + {props.description} + + + + {props.buttons} + +); + +export default DigitalTwinCard; diff --git a/client/src/route/digitaltwins/DigitalTwinsPreview.tsx b/client/src/route/digitaltwins/DigitalTwinsPreview.tsx new file mode 100644 index 000000000..7ecd9b569 --- /dev/null +++ b/client/src/route/digitaltwins/DigitalTwinsPreview.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from 'react'; +import { Typography } from '@mui/material'; +import Layout from 'page/Layout'; +import TabComponent from 'components/tab/TabComponent'; +import { TabData } from 'components/tab/subcomponents/TabRender'; +import { Asset } from 'components/asset/Asset'; +import AssetBoard from 'components/asset/AssetBoard'; +import tabs from './DigitalTwinTabData'; +import GitlabService from './GitlabService'; + +const createDTTab = ( + subfolders: Asset[], + error: string | null, + gitlabInstance: GitlabService, +): TabData[] => tabs + .filter((tab) => tab.label === 'Execute') + .map((tab) => ({ + label: tab.label, + body: ( + <> + {tab.body} + + > + ), + })); + +const fetchSubfolders = async ( + gitlabService: GitlabService, + setSubfolders: React.Dispatch>, + setError: React.Dispatch>, +) => { + const result = await gitlabService.getSubfolders(); + if (typeof result === 'string') { + setError(result); + } else { + setSubfolders(result); + } +}; + +function DTContent() { + const [subfolders, setSubfolders] = useState([]); + const [error, setError] = useState(null); + const gitlabService = new GitlabService(); + + useEffect(() => { + fetchSubfolders(gitlabService, setSubfolders, setError); + }, [gitlabService]); + + return ( + + + + ); +} + +export default DTContent; diff --git a/client/src/route/digitaltwins/ExecuteDigitalTwin.tsx b/client/src/route/digitaltwins/ExecuteDigitalTwin.tsx new file mode 100644 index 000000000..3a568e6a6 --- /dev/null +++ b/client/src/route/digitaltwins/ExecuteDigitalTwin.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect } from 'react'; +import { + CardActions, + Typography, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Snackbar, + Alert, + AlertColor, + CircularProgress, +} from '@mui/material'; +import { GitlabInstance } from 'util/gitlab'; +import DigitalTwin from 'util/gitlabDigitalTwin'; +import stripAnsi from 'strip-ansi'; +import { getAuthority } from 'util/envUtil'; +import DigitalTwinCard from './DigitalTwinCard'; + +const formatName = (name: string) => + name.replace(/-/g, ' ').replace(/^./, (char) => char.toUpperCase()); + +const ExecuteDigitalTwin: React.FC<{ name: string }> = (props) => { + const [gitlabInstance] = useState( + new GitlabInstance( + sessionStorage.getItem('username') || '', + getAuthority(), + sessionStorage.getItem('access_token') || '', + ), + ); + const [digitalTwin, setDigitalTwin] = useState(null); + const [description, setDescription] = useState(''); + const [executionStatus, setExecutionStatus] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const [snackbarSeverity, setSnackbarSeverity] = + useState('success'); + const [jobLogs, setJobLogs] = useState<{ jobName: string; log: string }[]>( + [], + ); + const [showLog, setShowLog] = useState(false); + const [pipelineCompleted, setPipelineCompleted] = useState(false); + const [pipelineLoading, setPipelineLoading] = useState(false); + const [buttonText, setButtonText] = useState('Start'); + const [executionCount, setExecutionCount] = useState(0); + + const initialize = async () => { + await gitlabInstance.init(); + const dt = new DigitalTwin(props.name, gitlabInstance); + await dt.init(); + setDigitalTwin(dt); + console.log('Digital twin:', dt); + setDescription(dt.description); + }; + + useEffect(() => { + initialize(); + }, []); + + useEffect(() => { + if (executionStatus) { + setSnackbarMessage( + `Execution ${executionStatus} for ${formatName(props.name)} (Run #${executionCount})`, + ); + setSnackbarSeverity(executionStatus === 'success' ? 'success' : 'error'); + setSnackbarOpen(true); + } + }, [executionStatus, executionCount, props.name]); + + const checkSecondPipelineStatus = async ( + projectId: number, + pipelineId: number, + ) => { + const pipelineStatus = gitlabInstance + ? await gitlabInstance.getPipelineStatus(projectId, pipelineId) + : null; + if (pipelineStatus === 'success' || pipelineStatus === 'failed') { + const pipelineIdJobs = pipelineId; + setJobLogs(await fetchJobLogs(projectId, pipelineIdJobs)); + setPipelineCompleted(true); + setPipelineLoading(false); + setButtonText('Start'); + } else { + setTimeout(() => checkSecondPipelineStatus(projectId, pipelineId), 5000); + } + }; + + const checkFirstPipelineStatus = async ( + projectId: number, + pipelineId: number, + ) => { + const pipelineStatus = gitlabInstance + ? await gitlabInstance.getPipelineStatus(projectId, pipelineId) + : null; + if (pipelineStatus === 'success' || pipelineStatus === 'failed') { + checkSecondPipelineStatus(projectId, pipelineId + 1); + } else { + setTimeout(() => checkFirstPipelineStatus(projectId, pipelineId), 5000); + } + }; + + const fetchJobLogs = async (projectId: number, pipelineId: number) => { + const jobs = gitlabInstance + ? await gitlabInstance.getPipelineJobs(projectId, pipelineId) + : []; + console.log('gitlabinstance job', gitlabInstance); + console.log(jobs); + const logPromises = jobs.map(async (job) => { + let log = gitlabInstance + ? await gitlabInstance.getJobTrace(projectId, job.id) + : ''; + console.log('Log in fetchJobLogs:', log); + if (typeof log === 'string') { + log = stripAnsi(log) + .split('\n') + .map((line) => + line + .replace(/section_start:\d+:[^A-Z]*/, '') + .replace(/section_end:\d+:[^A-Z]*/, ''), + ) + .join('\n'); + } + return { jobName: job.name, log }; + }); + return (await Promise.all(logPromises)).reverse(); + }; + + const handleStart = async () => { + if (digitalTwin) { + if (buttonText === 'Start') { + setButtonText('Stop'); + setJobLogs([]); + setPipelineCompleted(false); + setPipelineLoading(true); + const pipelineId = await digitalTwin.execute(); + setExecutionStatus(digitalTwin.executionStatus()); + setExecutionCount((prevCount) => prevCount + 1); + + if ( + gitlabInstance && + gitlabInstance.projectId && + digitalTwin?.pipelineId && + pipelineId + ) { + checkFirstPipelineStatus(gitlabInstance.projectId, pipelineId); + } + } else { + setButtonText('Start'); + } + } + }; + + const handleStop = async () => { + if (digitalTwin) { + try { + if ( + gitlabInstance && + gitlabInstance.projectId && + digitalTwin.pipelineId + ) { + await digitalTwin.stop( + gitlabInstance.projectId, + digitalTwin.pipelineId, + ); + } + setSnackbarMessage( + `${formatName(props.name)} (Run #${executionCount}) execution stopped successfully`, + ); + setSnackbarSeverity('success'); + } catch (error) { + setSnackbarMessage( + `Failed to stop ${formatName(props.name)} (Run #${executionCount}) execution`, + ); + setSnackbarSeverity('error'); + } finally { + setSnackbarOpen(true); + setButtonText('Start'); + setPipelineCompleted(true); + setPipelineLoading(false); + } + } + }; + + const handleButtonClick = () => { + if (buttonText === 'Start') { + handleStart(); + } else { + handleStop(); + } + }; + + const handleToggleLog = () => { + setShowLog((prev) => !prev); + }; + + const handleCloseLog = () => { + setShowLog(false); + }; + + const handleCloseSnackbar = () => { + setSnackbarOpen(false); + }; + + return ( + <> + {description ? ( + + + {buttonText} + + + Log + + {pipelineLoading ? : null} + + } + /> + ) : ( + Loading... + )} + + + {`${formatName(props.name)} - log (run #${executionCount})`} + + {jobLogs.length > 0 ? ( + jobLogs.map((jobLog, index) => ( + + {jobLog.jobName} + + {jobLog.log} + + + )) + ) : ( + No logs available + )} + + + + Close + + + + + + + {snackbarMessage} + + + > + ); +}; + +export default ExecuteDigitalTwin; diff --git a/client/src/route/digitaltwins/ExecuteTab.tsx b/client/src/route/digitaltwins/ExecuteTab.tsx new file mode 100644 index 000000000..aea9166cc --- /dev/null +++ b/client/src/route/digitaltwins/ExecuteTab.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Grid, Box, Typography } from '@mui/material'; +import ExecuteDigitalTwin from 'route/digitaltwins/ExecuteDigitalTwin'; +import { GitlabInstance, FolderEntry } from 'util/gitlab'; + +const ExecuteTab: React.FC<{ + subfolders: FolderEntry[]; + gitlabInstance: GitlabInstance; + error: string | null; +}> = (props) => ( + + {props.error ? ( + {props.error} + ) : ( + + {props.subfolders.map((folder) => ( + + {props.gitlabInstance && } + + ))} + + )} + +); + +export default ExecuteTab; diff --git a/client/src/route/digitaltwins/ExecutionFunctions.ts b/client/src/route/digitaltwins/ExecutionFunctions.ts new file mode 100644 index 000000000..1a7a2cef2 --- /dev/null +++ b/client/src/route/digitaltwins/ExecutionFunctions.ts @@ -0,0 +1,255 @@ +import { Dispatch, SetStateAction } from 'react'; +import { AlertColor } from '@mui/material'; +import DigitalTwin from 'util/gitlabDigitalTwin'; +import { GitlabInstance } from 'util/gitlab'; +import stripAnsi from 'strip-ansi'; +import { JobLog } from '../../components/asset/StartStopButton'; + +export const handleButtonClick = ( + buttonText: string, + setButtonText: Dispatch>, + setJobLogs: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, + setExecutionStatus: Dispatch>, + setExecutionCount: Dispatch>, + digitalTwin: DigitalTwin, + setSnackbarMessage: Dispatch>, + setSnackbarSeverity: Dispatch>, + setSnackbarOpen: Dispatch>, + executionCount: number, +) => { + if (buttonText === 'Start') { + handleStart( + buttonText, + setButtonText, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setExecutionStatus, + setExecutionCount, + digitalTwin, + ); + } else { + handleStop( + digitalTwin, + setSnackbarMessage, + setSnackbarSeverity, + setSnackbarOpen, + executionCount, + setButtonText, + setPipelineCompleted, + setPipelineLoading, + ); + } +}; + +export const handleStart = async ( + buttonText: string, + setButtonText: Dispatch>, + setJobLogs: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, + setExecutionStatus: Dispatch>, + setExecutionCount: Dispatch>, + digitalTwin: DigitalTwin, +) => { + if (buttonText === 'Start') { + setButtonText('Stop'); + setJobLogs([]); + setPipelineCompleted(false); + setPipelineLoading(true); + await digitalTwin.execute(); + setExecutionStatus(digitalTwin.executionStatus()); + setExecutionCount((prevCount) => prevCount + 1); + + checkFirstPipelineStatus( + digitalTwin.gitlabInstance, + digitalTwin.pipelineId!, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setButtonText, + ); + } else { + setButtonText('Start'); + } +}; + +export const checkFirstPipelineStatus = async ( + gitlabInstance: GitlabInstance, + pipelineId: number, + setJobLogs: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, + setButtonText: Dispatch>, +) => { + const pipelineStatus = await gitlabInstance.getPipelineStatus( + gitlabInstance.projectId!, + pipelineId, + ); + + if (pipelineStatus === 'success' || pipelineStatus === 'failed') { + await handlePipelineCompletion( + gitlabInstance, + pipelineId + 1, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setButtonText, + ); + } else { + retryPipelineCheck( + gitlabInstance, + pipelineId, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setButtonText, + ); + } +}; + +const handlePipelineCompletion = async ( + gitlabInstance: GitlabInstance, + nextPipelineId: number, + setJobLogs: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, + setButtonText: Dispatch>, +) => { + await checkSecondPipelineStatus( + gitlabInstance, + nextPipelineId, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setButtonText, + ); +}; + +const retryPipelineCheck = ( + gitlabInstance: GitlabInstance, + pipelineId: number, + setJobLogs: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, + setButtonText: Dispatch>, +) => { + setTimeout( + () => + checkFirstPipelineStatus( + gitlabInstance, + pipelineId, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setButtonText, + ), + 5000, + ); +}; + +export const checkSecondPipelineStatus = async ( + gitlabInstance: GitlabInstance, + pipelineId: number, + setJobLogs: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, + setButtonText: Dispatch>, +) => { + const pipelineStatus = await gitlabInstance.getPipelineStatus( + gitlabInstance.projectId!, + pipelineId, + ); + if (pipelineStatus === 'success' || pipelineStatus === 'failed') { + const pipelineIdJobs = pipelineId; + setJobLogs(await fetchJobLogs(gitlabInstance, pipelineIdJobs)); + setPipelineCompleted(true); + setPipelineLoading(false); + setButtonText('Start'); + } else { + setTimeout( + () => + checkSecondPipelineStatus( + gitlabInstance, + pipelineId, + setJobLogs, + setPipelineCompleted, + setPipelineLoading, + setButtonText, + ), + 5000, + ); + } +}; + +export const fetchJobLogs = async ( + gitlabInstance: GitlabInstance, + pipelineId: number, +) => { + const jobs = await gitlabInstance.getPipelineJobs( + gitlabInstance.projectId!, + pipelineId, + ); + const logPromises = jobs.map(async (job) => { + let log = await gitlabInstance.getJobTrace( + gitlabInstance.projectId!, + job.id, + ); + if (typeof log === 'string') { + log = stripAnsi(log) + .split('\n') + .map((line) => + line + .replace(/section_start:\d+:[^A-Z]*/, '') + .replace(/section_end:\d+:[^A-Z]*/, ''), + ) + .join('\n'); + } + return { jobName: job.name, log }; + }); + return (await Promise.all(logPromises)).reverse(); +}; + +export const handleStop = async ( + digitalTwin: DigitalTwin, + setSnackbarMessage: Dispatch>, + setSnackbarSeverity: Dispatch>, + setSnackbarOpen: Dispatch>, + executionCount: number, + setButtonText: Dispatch>, + setPipelineCompleted: Dispatch>, + setPipelineLoading: Dispatch>, +) => { + try { + await stopPipelines(digitalTwin); + setSnackbarMessage( + `${digitalTwin.DTName} (Run #${executionCount}) execution stopped successfully`, + ); + setSnackbarSeverity('success'); + } catch (error) { + setSnackbarMessage( + `Failed to stop ${digitalTwin.DTName} (Run #${executionCount}) execution`, + ); + setSnackbarSeverity('error'); + } finally { + setSnackbarOpen(true); + setButtonText('Start'); + setPipelineCompleted(true); + setPipelineLoading(false); + } +}; + +const stopPipelines = async (digitalTwin: DigitalTwin) => { + if (digitalTwin.gitlabInstance.projectId && digitalTwin.pipelineId) { + await digitalTwin.stop( + digitalTwin.gitlabInstance.projectId, + digitalTwin.pipelineId, + ); + await digitalTwin.stop( + digitalTwin.gitlabInstance.projectId, + digitalTwin.pipelineId + 1, + ); + } +}; diff --git a/client/src/route/digitaltwins/GitlabService.ts b/client/src/route/digitaltwins/GitlabService.ts new file mode 100644 index 000000000..d1295384e --- /dev/null +++ b/client/src/route/digitaltwins/GitlabService.ts @@ -0,0 +1,38 @@ +import { GitlabInstance } from 'util/gitlab'; +import { getAuthority } from 'util/envUtil'; +import { Asset } from '../../components/asset/Asset'; + +class GitlabService { + private gitlabInstance: GitlabInstance; + + constructor() { + this.gitlabInstance = new GitlabInstance( + sessionStorage.getItem('username') || '', + getAuthority(), + sessionStorage.getItem('access_token') || '', + ); + } + + async getSubfolders(): Promise { + try { + if (!this.gitlabInstance) { + throw new Error('GitlabInstance is not initialized'); + } + await this.gitlabInstance.init(); + if (this.gitlabInstance.projectId) { + return this.gitlabInstance.getDTSubfolders( + this.gitlabInstance.projectId, + ); + } + return []; + } catch (error) { + return 'An error occurred'; + } + } + + getInstance(): GitlabInstance { + return this.gitlabInstance; + } +} + +export default GitlabService; diff --git a/client/src/route/digitaltwins/LogDialog.tsx b/client/src/route/digitaltwins/LogDialog.tsx new file mode 100644 index 000000000..78bb7e709 --- /dev/null +++ b/client/src/route/digitaltwins/LogDialog.tsx @@ -0,0 +1,60 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from '@mui/material'; +import React, { Dispatch, SetStateAction } from 'react'; + +interface LogDialogProps { + showLog: boolean; + setShowLog: Dispatch>; + name: string; + executionCount: number; + jobLogs: { jobName: string; log: string }[]; +} + +const handleCloseLog = (setShowLog: Dispatch>) => { + setShowLog(false); +}; + +function LogDialog({ + showLog, + setShowLog, + name, + executionCount, + jobLogs, +}: LogDialogProps) { + return ( + handleCloseLog(setShowLog)} + maxWidth="md" + > + {`${name} - log (run #${executionCount})`} + + {jobLogs.length > 0 ? ( + jobLogs.map((jobLog, index) => ( + + {jobLog.jobName} + + {jobLog.log} + + + )) + ) : ( + No logs available + )} + + + handleCloseLog(setShowLog)} color="primary"> + Close + + + + ); +} + +export default LogDialog; diff --git a/client/src/route/digitaltwins/Snackbar.tsx b/client/src/route/digitaltwins/Snackbar.tsx new file mode 100644 index 000000000..8451cddaa --- /dev/null +++ b/client/src/route/digitaltwins/Snackbar.tsx @@ -0,0 +1,38 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; + +interface CustomSnackbarProps { + snackbarOpen: boolean; + snackbarMessage: string; + snackbarSeverity: 'success' | 'error' | 'warning' | 'info'; + setSnackbarOpen: Dispatch>; +} + +const handleCloseSnackbar = ( + setSnackbarOpen: Dispatch>, +) => { + setSnackbarOpen(false); +}; + +const CustomSnackbar: React.FC = ({ + snackbarOpen, + snackbarMessage, + snackbarSeverity, + setSnackbarOpen, +}) => ( + handleCloseSnackbar(setSnackbarOpen)} + > + handleCloseSnackbar(setSnackbarOpen)} + severity={snackbarSeverity} + > + {snackbarMessage} + + +); + +export default CustomSnackbar; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index cd0890198..8ab9f0427 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -4,6 +4,7 @@ import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; import Library from './route/library/Library'; import DigitalTwins from './route/digitaltwins/DigitalTwins'; +import DigitalTwinsPreview from './route/digitaltwins/DigitalTwinsPreview'; import SignIn from './route/auth/Signin'; import Account from './route/auth/Account'; @@ -48,6 +49,14 @@ export const routes = [ ), }, + { + path: 'preview/digitaltwins', + element: ( + + + + ), + }, ]; export default routes; diff --git a/client/src/store/CartAccess.ts b/client/src/store/CartAccess.ts new file mode 100644 index 000000000..4cedcfc03 --- /dev/null +++ b/client/src/store/CartAccess.ts @@ -0,0 +1,18 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { Asset } from 'components/asset/Asset'; +import * as cart from './cart.slice'; +import { RootState } from './store'; + +function useCart() { + const dispatch = useDispatch(); + const state = useSelector((store: RootState) => store.cart); + const actions = { + add: (asset: Asset) => dispatch(cart.addToCart(asset)), + remove: (asset: Asset) => dispatch(cart.removeFromCart(asset)), + clear: () => dispatch(cart.clearCart()), + }; + + return { state, actions }; +} + +export default useCart; diff --git a/client/src/store/cart.slice.ts b/client/src/store/cart.slice.ts new file mode 100644 index 000000000..28d651cff --- /dev/null +++ b/client/src/store/cart.slice.ts @@ -0,0 +1,30 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Asset } from 'components/asset/Asset'; + +export interface CartState { + assets: Asset[]; +} + +const initState: CartState = { + assets: [], +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState: initState, + reducers: { + addToCart: (state: CartState, action: PayloadAction) => { + if (!state.assets.find((asset) => asset.path === action.payload.path)) + state.assets.push(action.payload); + }, + removeFromCart: (state: CartState, action: PayloadAction) => { + state.assets = state.assets.filter((a) => a.path !== action.payload.path); + }, + clearCart: (state: CartState) => { + state.assets = []; + }, + }, +}); + +export const { addToCart, removeFromCart, clearCart } = cartSlice.actions; +export default cartSlice.reducer; diff --git a/client/src/store/digitalTwin.slice.ts b/client/src/store/digitalTwin.slice.ts new file mode 100644 index 000000000..52a213890 --- /dev/null +++ b/client/src/store/digitalTwin.slice.ts @@ -0,0 +1,24 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import DigitalTwin from 'util/gitlabDigitalTwin'; + +interface DigitalTwinState { + [key: string]: DigitalTwin; +} + +const initialState: DigitalTwinState = {}; + +const digitalTwinSlice = createSlice({ + name: 'digitalTwin', + initialState, + reducers: { + setDigitalTwin: ( + state, + action: PayloadAction<{ assetName: string; digitalTwin: DigitalTwin }>, + ) => { + state[action.payload.assetName] = action.payload.digitalTwin; + }, + }, +}); + +export const { setDigitalTwin } = digitalTwinSlice.actions; +export default digitalTwinSlice.reducer; diff --git a/client/src/store/store.ts b/client/src/store/store.ts index 566c8b5f6..7bcf42f01 100644 --- a/client/src/store/store.ts +++ b/client/src/store/store.ts @@ -2,10 +2,14 @@ import { combineReducers } from 'redux'; import { configureStore } from '@reduxjs/toolkit'; import menuSlice from './menu.slice'; import authSlice from './auth.slice'; +import cartReducer from './cart.slice'; +import digitalTwinSlice from './digitalTwin.slice'; const rootReducer = combineReducers({ menu: menuSlice, auth: authSlice, + cart: cartReducer, + digitalTwin: digitalTwinSlice, }); const store = configureStore({ @@ -13,4 +17,5 @@ const store = configureStore({ }); export type RootState = ReturnType; + export default store; diff --git a/client/src/util/envUtil.ts b/client/src/util/envUtil.ts index 490e57a5b..b82b0a078 100644 --- a/client/src/util/envUtil.ts +++ b/client/src/util/envUtil.ts @@ -62,9 +62,13 @@ export function getWorkbenchLinkValues(): KeyLinkPair[] { const value = window.env[key]; if (value !== undefined) { const keyWithoutPrefix = key.slice(prefix.length); + const linkValue = + keyWithoutPrefix === 'DT_PREVIEW' + ? value + : useUserLink(useAppURL(), value); workbenchLinkValues.push({ key: keyWithoutPrefix, - link: useUserLink(useAppURL(), value), + link: linkValue, }); } }); @@ -72,6 +76,10 @@ export function getWorkbenchLinkValues(): KeyLinkPair[] { return workbenchLinkValues; } +export function getDTPagePreviewLink(): string { + return useUserLink(useAppURL(), 'preview/digitaltwins'); +} + export function getClientID(): string { return window.env.REACT_APP_CLIENT_ID; } diff --git a/client/src/util/gitlab.ts b/client/src/util/gitlab.ts new file mode 100644 index 000000000..a0d85dc17 --- /dev/null +++ b/client/src/util/gitlab.ts @@ -0,0 +1,127 @@ +import { Gitlab } from '@gitbeaker/rest'; +import { Asset } from '../components/asset/Asset'; + +const GROUP_NAME = 'DTaaS'; +const DT_DIRECTORY = 'digital_twins'; + +interface LogEntry { + status: string; + DTName: string; + runnerTag: string; + error?: Error; +} + +interface FolderEntry { + name: string; + path: string; + description: string; +} + +class GitlabInstance { + public username: string | null; + + public api: InstanceType; + + public logs: LogEntry[]; + + public subfolders: Asset[]; + + public projectId: number | null = null; + + public triggerToken: string | null = null; + + constructor(username: string, host: string, oauthToken: string) { + this.username = username; + this.api = new Gitlab({ + host, + oauthToken, + }); + this.logs = []; + this.subfolders = []; + } + + async init() { + const projectId = await this.getProjectId(); + this.projectId = projectId; + + if (this.projectId !== null) { + const token = await this.getTriggerToken(this.projectId); + this.triggerToken = token; + } + } + + async getProjectId(): Promise { + let projectId: number | null = null; + + const group = await this.api.Groups.show(GROUP_NAME); + const projects = await this.api.Groups.allProjects(group.id); + const project = projects.find((proj) => proj.name === this.username); + + if (project) { + projectId = project.id; + } + return projectId; + } + + async getTriggerToken(projectId: number): Promise { + let token: string | null = null; + + const triggers = await this.api.PipelineTriggerTokens.all(projectId); + + if (triggers && triggers.length > 0) { + token = triggers[0].token; + } + return token; + } + + async getDTDescription(DTName: string) { + const readmePath = `digital_twins/${DTName}/description.md`; + const fileData = await this.api.RepositoryFiles.show( + this.projectId!, + readmePath, + 'main', + ); + return atob(fileData.content); + } + + async getDTSubfolders(projectId: number): Promise { + const files = await this.api.Repositories.allRepositoryTrees(projectId, { + path: DT_DIRECTORY, + recursive: false, + }); + + const subfolders: Asset[] = await Promise.all( + files + .filter((file) => file.type === 'tree' && file.path !== DT_DIRECTORY) + .map(async (file) => ({ + name: file.name, + path: file.path, // Ensure the path property is included + description: await this.getDTDescription(file.name), // Await the description + })), + ); + + this.subfolders = subfolders; + return subfolders; + } + + executionLogs(): LogEntry[] { + return this.logs; + } + + async getPipelineJobs(projectId: number, pipelineId: number) { + const jobs = await this.api.Jobs.all(projectId, { pipelineId }); + return jobs; + } + + async getJobTrace(projectId: number, jobId: number) { + const log = await this.api.Jobs.showLog(projectId, jobId); + return log; + } + + async getPipelineStatus(projectId: number, pipelineId: number) { + const pipeline = await this.api.Pipelines.show(projectId, pipelineId); + return pipeline.status; + } +} + +export { GitlabInstance, FolderEntry }; diff --git a/client/src/util/gitlabDigitalTwin.ts b/client/src/util/gitlabDigitalTwin.ts new file mode 100644 index 000000000..acdedbbd0 --- /dev/null +++ b/client/src/util/gitlabDigitalTwin.ts @@ -0,0 +1,112 @@ +import { GitlabInstance } from './gitlab'; + +const RUNNER_TAG = 'linux'; + +class DigitalTwin { + public DTName: string; + + public description: string = ''; + + public gitlabInstance: GitlabInstance; + + public pipelineId: number | null = null; + + public lastExecutionStatus: string | null = null; + + constructor(DTName: string, gitlabInstance: GitlabInstance) { + this.DTName = DTName; + this.gitlabInstance = gitlabInstance; + } + + async init() { + if (this.gitlabInstance.projectId) { + const readmePath = `digital_twins/${this.DTName}/description.md`; + const fileData = await this.gitlabInstance.api.RepositoryFiles.show( + this.gitlabInstance.projectId, + readmePath, + 'main', + ); + this.description = atob(fileData.content); + } else { + this.description = 'Error fetching description.'; + } + } + + async execute(): Promise { + if (!this.isValidInstance()) { + this.logError('Missing projectId or triggerToken'); + return null; + } + + try { + const response = await this.triggerPipeline(); + this.logSuccess(); + this.pipelineId = response.id; + return this.pipelineId; + } catch (error) { + this.logError(String(error)); + return null; + } + } + + private isValidInstance(): boolean { + return !!( + this.gitlabInstance.projectId && this.gitlabInstance.triggerToken + ); + } + + private async triggerPipeline() { + const variables = { DTName: this.DTName, RunnerTag: RUNNER_TAG }; + return this.gitlabInstance.api.PipelineTriggerTokens.trigger( + this.gitlabInstance.projectId!, + 'main', + this.gitlabInstance.triggerToken!, + { variables }, + ); + } + + private logSuccess(): void { + this.gitlabInstance.logs.push({ + status: 'success', + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'success'; + } + + private logError(error: string): void { + this.gitlabInstance.logs.push({ + status: 'error', + error: new Error(error), + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'error'; + } + + async stop(projectId: number, pipelineId: number): Promise { + try { + await this.gitlabInstance.api.Pipelines.cancel(projectId, pipelineId); + this.gitlabInstance.logs.push({ + status: 'canceled', + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'canceled'; + } catch (error) { + this.gitlabInstance.logs.push({ + status: 'error', + error: new Error(String(error)), + DTName: this.DTName, + runnerTag: RUNNER_TAG, + }); + this.lastExecutionStatus = 'error'; + } + } + + executionStatus(): string | null { + return this.lastExecutionStatus; + } +} + +export default DigitalTwin; diff --git a/client/src/util/gitlabDriver.ts b/client/src/util/gitlabDriver.ts new file mode 100755 index 000000000..83a05c2ab --- /dev/null +++ b/client/src/util/gitlabDriver.ts @@ -0,0 +1,44 @@ +import { GitlabInstance } from './gitlab.js'; +import DigitalTwin from './gitlabDigitalTwin.js'; +import * as config from '../../config/gitlab.json' assert { type: 'json' }; + +class GitlabDriver { + public static async run(): Promise { + const gitlabInstance = new GitlabInstance( + config.username, + config.host, + config.oauth_token, + ); + console.log('GitLab username:', gitlabInstance.username); + console.log('GitLab logs:', gitlabInstance.logs); + console.log('GitLab subfolders:', gitlabInstance.subfolders); + + const projectId = (await gitlabInstance.getProjectId()) || 0; + console.log('Project id:', projectId); + + const subfolders = await gitlabInstance.getDTSubfolders(projectId); + console.log('Subfolders:', subfolders); + + const dtName = subfolders[0].name; + + const triggerToken = await gitlabInstance.getTriggerToken(projectId); + console.log('Trigger token:', triggerToken); + + const digitalTwin = new DigitalTwin(dtName, gitlabInstance); + const result = await digitalTwin.execute(); + + console.log('Execution Result:', result); + + const lastExecutionStatus = digitalTwin.executionStatus(); + console.log('Execution Status:', lastExecutionStatus); + + const logs = gitlabInstance.executionLogs(); + console.log('Execution Logs:', logs); + } +} + +GitlabDriver.run().catch((error) => { + console.error('Error executing GitlabDriver:', error); +}); + +export default GitlabDriver; diff --git a/client/test/README.md b/client/test/README.md index a2c5d556a..3d2b84d04 100644 --- a/client/test/README.md +++ b/client/test/README.md @@ -104,6 +104,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', @@ -145,6 +146,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/client/test/unitTests/Util/gitlab.test.ts b/client/test/unitTests/Util/gitlab.test.ts new file mode 100644 index 000000000..6e82034f5 --- /dev/null +++ b/client/test/unitTests/Util/gitlab.test.ts @@ -0,0 +1,126 @@ +import { Gitlab } from '@gitbeaker/rest'; +import GitlabInstance from 'util/gitlab'; + +jest.mock('@gitbeaker/rest'); + +describe('GitlabInstance', () => { + let gitlab: GitlabInstance; + const mockApi = { + Groups: { + show: jest.fn(), + allProjects: jest.fn(), + }, + PipelineTriggerTokens: { + all: jest.fn(), + trigger: jest.fn(), + }, + Repositories: { + allRepositoryTrees: jest.fn(), + }, + }; + + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + + gitlab = new GitlabInstance( + 'user1', + 'https://gitlab.example.com', + 'test_token', + ); + gitlab.api = mockApi as unknown as InstanceType; + }); + + it('should initialize the Gitlab API with the correct parameters', () => { + expect(Gitlab).toHaveBeenCalledWith({ + host: 'https://gitlab.example.com', + oauthToken: 'test_token', + }); + }); + + it('should fetch project ID successfully', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([{ id: 1, name: 'user1' }]); + + const projectId = await gitlab.getProjectId(); + + expect(projectId).toBe(1); + expect(mockApi.Groups.show).toHaveBeenCalledWith('DTaaS'); + expect(mockApi.Groups.allProjects).toHaveBeenCalledWith(1); + }); + + it('should handle project ID not found', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([]); + + const projectId = await gitlab.getProjectId(); + + expect(projectId).toBeNull(); + }); + + it('should fetch trigger token successfully', async () => { + mockApi.PipelineTriggerTokens.all.mockResolvedValue([ + { token: 'test-token' }, + ]); + + const token = await gitlab.getTriggerToken(1); + + expect(token).toBe('test-token'); + expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); + }); + + it('should handle no trigger tokens found', async () => { + mockApi.PipelineTriggerTokens.all.mockResolvedValue([]); + + const token = await gitlab.getTriggerToken(1); + + expect(token).toBeNull(); + expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); + }); + + it('should handle undefined trigger tokens', async () => { + mockApi.PipelineTriggerTokens.all.mockResolvedValue(undefined); + + const token = await gitlab.getTriggerToken(1); + + expect(token).toBeNull(); + }); + + it('should fetch DT subfolders successfully', async () => { + mockApi.Repositories.allRepositoryTrees.mockResolvedValue([ + { name: 'subfolder1', path: 'digital_twins/subfolder1', type: 'tree' }, + { name: 'subfolder2', path: 'digital_twins/subfolder2', type: 'tree' }, + { name: 'file1', path: 'digital_twins/file1', type: 'blob' }, + ]); + + const subfolders = await gitlab.getDTSubfolders(1); + + expect(subfolders).toHaveLength(2); + expect(subfolders).toEqual([ + { name: 'subfolder1', path: 'digital_twins/subfolder1' }, + { name: 'subfolder2', path: 'digital_twins/subfolder2' }, + ]); + expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith(1, { + path: 'digital_twins', + recursive: false, + }); + }); + + it('should return execution logs', () => { + const mockLog = { + status: 'success', + DTName: 'test-DTName', + runnerTag: 'test-runnerTag', + error: undefined, + }; + + gitlab.logs.push(mockLog); + + const logs = gitlab.executionLogs(); + + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('success'); + expect(logs[0].DTName).toBe('test-DTName'); + expect(logs[0].runnerTag).toBe('test-runnerTag'); + }); +}); diff --git a/client/test/unitTests/Util/gitlabDigitalTwin.test.ts b/client/test/unitTests/Util/gitlabDigitalTwin.test.ts new file mode 100644 index 000000000..351c928b2 --- /dev/null +++ b/client/test/unitTests/Util/gitlabDigitalTwin.test.ts @@ -0,0 +1,162 @@ +import { ProjectSchema, PipelineTriggerTokenSchema } from '@gitbeaker/rest'; +import DigitalTwin from 'util/gitlabDigitalTwin'; +import { GitlabInstance } from 'util/gitlab'; + +type LogEntry = { status: string; DTName: string; runnerTag: string }; + +const mockApi = { + Groups: { + show: jest.fn(), + allProjects: jest.fn(), + }, + PipelineTriggerTokens: { + all: jest.fn(), + trigger: jest.fn(), + }, + Repositories: { + allRepositoryTrees: jest.fn(), + }, +}; + +const mockGitlabInstance = { + api: mockApi as unknown as GitlabInstance['api'], + executionLogs: jest.fn() as jest.Mock, + getProjectId: jest.fn(), + getTriggerToken: jest.fn(), + getDTSubfolders: jest.fn(), + logs: [], +} as unknown as GitlabInstance; + +describe('DigitalTwin', () => { + let dt: DigitalTwin; + + beforeEach(() => { + dt = new DigitalTwin('test-DTName', mockGitlabInstance); + }); + + it('should handle null project ID during pipeline execution', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([]); + (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(null); + + const success = await dt.execute(); + + expect(success).toBe(false); + expect(dt.executionStatus()).toBe('error'); + expect(mockApi.PipelineTriggerTokens.trigger).not.toHaveBeenCalled(); + }); + + it('should handle null trigger token during pipeline execution', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([ + { id: 1, name: 'user1' } as ProjectSchema, + ]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([]); + (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue(null); + + const success = await dt.execute(); + + expect(success).toBe(false); + expect(dt.executionStatus()).toBe('error'); + expect(mockApi.PipelineTriggerTokens.trigger).not.toHaveBeenCalled(); + }); + + it('should execute pipeline successfully', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([ + { id: 1, name: 'user1' } as ProjectSchema, + ]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([ + { token: 'test-token' } as PipelineTriggerTokenSchema, + ]); + (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(1); + (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue( + 'test-token', + ); + (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockResolvedValue( + undefined, + ); + + const success = await dt.execute(); + + expect(success).toBe(true); + expect(dt.executionStatus()).toBe('success'); + expect(mockApi.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + 1, + 'main', + 'test-token', + { variables: { DTName: 'test-DTName', RunnerTag: 'test-runnerTag' } }, + ); + }); + + it('should handle non-Error thrown during pipeline execution', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([ + { id: 1, name: 'user1' } as ProjectSchema, + ]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([ + { token: 'test-token' } as PipelineTriggerTokenSchema, + ]); + (mockGitlabInstance.getProjectId as jest.Mock).mockResolvedValue(1); + (mockGitlabInstance.getTriggerToken as jest.Mock).mockResolvedValue( + 'test-token', + ); + (mockApi.PipelineTriggerTokens.trigger as jest.Mock).mockRejectedValue( + 'String error message', + ); + + const success = await dt.execute(); + + expect(success).toBe(false); + expect(dt.executionStatus()).toBe('error'); + expect(mockApi.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + 1, + 'main', + 'test-token', + { variables: { DTName: 'test-DTName', RunnerTag: 'test-runnerTag' } }, + ); + }); + + it('should handle Error thrown during pipeline execution', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([ + { id: 1, name: 'user1' } as ProjectSchema, + ]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([ + { token: 'test-token' } as PipelineTriggerTokenSchema, + ]); + + mockApi.PipelineTriggerTokens.trigger.mockRejectedValue( + new Error('Error instance message'), + ); + + const success = await dt.execute(); + + expect(success).toBe(false); + + expect(dt.executionStatus()).toBe('error'); + }); + + it('should return execution logs', async () => { + mockApi.Groups.show.mockResolvedValue({ id: 1, name: 'DTaaS' }); + mockApi.Groups.allProjects.mockResolvedValue([ + { id: 1, name: 'user1' } as ProjectSchema, + ]); + mockApi.PipelineTriggerTokens.all.mockResolvedValue([ + { token: 'test-token' } as PipelineTriggerTokenSchema, + ]); + mockApi.PipelineTriggerTokens.trigger.mockResolvedValue(undefined); + + await dt.execute(); + + (mockGitlabInstance.executionLogs as jest.Mock).mockReturnValue([ + { status: 'success', DTName: 'test-DTName', runnerTag: 'test-runnerTag' }, + ]); + + const logs = dt.gitlabInstance.executionLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('success'); + expect(logs[0].DTName).toBe('test-DTName'); + expect(logs[0].runnerTag).toBe('test-runnerTag'); + }); +}); diff --git a/client/tsconfig.gitlab.json b/client/tsconfig.gitlab.json new file mode 100644 index 000000000..6d5ffb336 --- /dev/null +++ b/client/tsconfig.gitlab.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "noImplicitAny": true, //raise error on any type + "allowSyntheticDefaultImports": true, //allow default imports from modules with no default export + "sourceMap": true, //generate .map files + "target": "es6", //target es6 + "lib": ["es2022", "webworker", "webworker.importscripts", "webworker.iterable", "scripthost", "es2022.array", "es2022.error", "es2022.intl", "es2022.object", "es2022.sharedmemory", "es2022.string"], + "jsx": "react", //use react + "types": ["react", "node"], //use react and node types + "module": "esnext", //use esnext modules + "moduleResolution": "node", //use node module resolution strategy node + "experimentalDecorators": true, //allow experimental decorators for es7 + "declaration": false, //don't generate declaration '.d.ts' files + "removeComments": true, //remove comments from build + "noImplicitReturns": true, //raise error on implicit returns + "noUnusedLocals": true, //raise error on unused locals + "noUnusedParameters": false, //raise no error on unused parameters + "strict": true, //enable all strict type-checking options + "outDir": "dist", //output directory + "baseUrl": "src", //base url for imports + "typeRoots": [ + "node_modules/@types" //use node_modules/@types for type definitions + ], + "strictNullChecks": true //enable strict null checks + }, + "exclude": ["**/node_modules/*", "babel.config.cjs", "dist", "test"], + "include": [ + "./src/util/gitlab*.ts", + "./test/unitTests/Util/gitlab*.test.ts" + ], + "typeRoots": [ "**/node_modules/@types" ] +} diff --git a/client/tsconfig.json b/client/tsconfig.json index ed79e285a..7dc6d30ec 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -28,7 +28,8 @@ "typeRoots": [ "node_modules/@types" //use node_modules/@types for type definitions ], - "strictNullChecks": true //enable strict null checks + "strictNullChecks": true, //enable strict null checks + "resolveJsonModule": true //allow to import JSON files directly }, "exclude": [ "**/node_modules/*", diff --git a/client/yarn.lock b/client/yarn.lock index 869742b40..3d24fe555 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1442,6 +1442,33 @@ resolved "https://registry.yarnpkg.com/@fontsource/roboto/-/roboto-5.0.8.tgz#613b477a56f21b5705db1a67e995c033ef317f76" integrity sha512-XxPltXs5R31D6UZeLIV1td3wTXU3jzd3f2DLsXI8tytMGBkIsGcc9sIyiupRtA8y73HAhuSCeweOoBqf6DbWCA== +"@gitbeaker/core@^40.1.3": + version "40.1.3" + resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-40.1.3.tgz#66e9ec61d2d3a8c1412e3fcfdf81e4bc92d2858c" + integrity sha512-704bRTVFI+2rropt/ZCxMp7HdlAbuOlvzlB5Hu8icBauh+NMOhAJFISDE3LG/Tbf1LXa1F5hSg8dVYzXYJNB9w== + dependencies: + "@gitbeaker/requester-utils" "^40.1.3" + qs "^6.12.2" + xcase "^2.0.1" + +"@gitbeaker/requester-utils@^40.1.3": + version "40.1.3" + resolved "https://registry.yarnpkg.com/@gitbeaker/requester-utils/-/requester-utils-40.1.3.tgz#d00825bc9318f526c9ff5556cad69ba495d6bf07" + integrity sha512-ruHu/lvvTdE6JPoUzEmiZY4Ef9U+5Iam5cgcB/vMYTfBx89iwlPGj/sGHIbJPWSdwoGnrn+sGp8+KygqDr3Zgw== + dependencies: + picomatch-browser "^2.2.6" + qs "^6.12.2" + rate-limiter-flexible "^4.0.1" + xcase "^2.0.1" + +"@gitbeaker/rest@^40.1.3": + version "40.1.3" + resolved "https://registry.yarnpkg.com/@gitbeaker/rest/-/rest-40.1.3.tgz#c061541f557c2cbf93694bff5fadab36c0318f6f" + integrity sha512-3xzImKoCTdlFyLUgnG+RjnWJmOtOhAFf7+A5+3r3nOCKOAZHGlknvPouNBQ2hCl8lRLBEHXgBA40ASv1SGvRfQ== + dependencies: + "@gitbeaker/core" "^40.1.3" + "@gitbeaker/requester-utils" "^40.1.3" + "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -2595,7 +2622,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.2.38": +"@types/react@*": version "18.2.38" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.38.tgz#3605ca41d3daff2c434e0b98d79a2469d4c2dd52" integrity sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw== @@ -2604,6 +2631,14 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^18.3.3": + version "18.3.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -2662,6 +2697,13 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/strip-ansi@^5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@types/strip-ansi/-/strip-ansi-5.2.1.tgz#acd97f1f091e332bb7ce697c4609eb2370fa2a92" + integrity sha512-1l5iM0LBkVU8JXxnIoBqNvg+yyOXxPeN6DNoD+7A9AN1B8FhYPSeIXgyNqwIqg1uzipTgVC2hmuDzB0u9qw/PA== + dependencies: + strip-ansi "*" + "@types/styled-components@^5.1.32": version "5.1.32" resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-5.1.32.tgz#58718971519c4562229ba85face98e8530d21bfd" @@ -3755,6 +3797,17 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5: get-intrinsic "^1.2.1" set-function-length "^1.1.1" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -4515,6 +4568,15 @@ define-data-property@^1.0.1, define-data-property@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -4877,6 +4939,18 @@ es-array-method-boxes-properly@^1.0.0: resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-get-iterator@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" @@ -5664,6 +5738,17 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -5833,6 +5918,13 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.2.2" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -8294,6 +8386,11 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== +picomatch-browser@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/picomatch-browser/-/picomatch-browser-2.2.6.tgz#e0626204575eb49f019f2f2feac24fc3b53e7a8a" + integrity sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -9014,6 +9111,13 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +qs@^6.12.2: + version "6.12.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.3.tgz#e43ce03c8521b9c7fd7f1f13e514e5ca37727754" + integrity sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ== + dependencies: + side-channel "^1.0.6" + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -9048,6 +9152,11 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +rate-limiter-flexible@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-4.0.1.tgz#79b0ce111abe9c5da41d6fddf7cca93cedd3a8fc" + integrity sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ== + raw-body@2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" @@ -9791,6 +9900,18 @@ set-function-length@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + set-function-name@^2.0.0, set-function-name@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" @@ -9858,6 +9979,16 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -10122,6 +10253,13 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" +strip-ansi@*, strip-ansi@^7.0.1, strip-ansi@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -10129,13 +10267,6 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.1: - version "7.1.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" - integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== - dependencies: - ansi-regex "^6.0.1" - strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -11325,6 +11456,11 @@ ws@^8.11.0, ws@^8.13.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== +xcase@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" + integrity sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw== + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" diff --git a/deploy/config/client/env.js b/deploy/config/client/env.js index 17fd6374f..979860b18 100644 --- a/deploy/config/client/env.js +++ b/deploy/config/client/env.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/deploy/config/client/env.local.js b/deploy/config/client/env.local.js index 9a9fc669b..e989f87bf 100644 --- a/deploy/config/client/env.local.js +++ b/deploy/config/client/env.local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', diff --git a/docs/admin/client/config.md b/docs/admin/client/config.md index 2712256bb..a9a70ac9c 100644 --- a/docs/admin/client/config.md +++ b/docs/admin/client/config.md @@ -15,6 +15,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_JUPYTERLAB: "Endpoint for the Jupyter Lab link", REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: "Endpoint for the Jupyter Notebook link", + REACT_APP_WORKBENCHLINK_DT_PREVIEW: "Endpoint for the Digital Twins page preview", REACT_APP_CLIENT_ID: 'AppID genereated by the gitlab OAuth provider', REACT_APP_AUTH_AUTHORITY: 'URL of the private gitlab instance', REACT_APP_REDIRECT_URI: 'URL of the homepage for the logged in users of the website', @@ -35,6 +36,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', REACT_APP_REDIRECT_URI: 'https://foo.com/Library', @@ -57,6 +59,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', REACT_APP_REDIRECT_URI: 'https://foo.com/bar/Library', diff --git a/docs/developer/client/GITLAB-RUNNER.md b/docs/developer/client/GITLAB-RUNNER.md new file mode 100644 index 000000000..5e389c330 --- /dev/null +++ b/docs/developer/client/GITLAB-RUNNER.md @@ -0,0 +1,34 @@ +# Gitlab Runner configuration + +To properly use the Digital Twins page preview, you need to configure at least +one project runner in your GitLab profile. Follow the steps below: + +1. Login to the GitLab profile that will be used as the OAuth provider. + +1. Navigate to the *DTaaS* group and select the project named after your + GitLab username. + +1. In the project menu, go to Settings and select CI/CD. + +1. Expand the **Runners** section and click on *New project runner*. Follow the + configuration instructions carefully: + - Add **linux** as a tag during configuration. + - Click on *Create runner*. + - Ensure GitLab Runner is installed before proceeding. Depending on your + environment, you will be shown the correct command to install GitLab Runner. + - Once GitLab Runner is installed, follow these steps to register the runner: + - Copy and paste the command shown in the GitLab interface into your command + line to register the runner. It includes a URL and a token for your specific + GitLab instance. + - Choose *docker* as executor when prompted by the command line. + - Choose the default docker image. You must use an image based on Linux, + like the default one (*ruby:2.7*). + +You can manually verify that the runner is available to pick up jobs by running +the following command: + +```bash +sudo gitlab-runner run +``` + +It can also be used to reactivate offline runners during subsequent sessions. \ No newline at end of file