mirror of
https://github.com/sasjs/server.git
synced 2025-12-11 03:34:35 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66232aefd2 | ||
|
|
bf35791655 | ||
|
|
2dc11630e4 | ||
|
|
1473925896 | ||
|
|
b472f1bd61 |
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
run: npm run lint-web
|
run: npm run lint-web
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": ["autoexec", "initialising"]
|
"cSpell.words": [
|
||||||
|
"autoexec"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,57 +1,3 @@
|
|||||||
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* extra bit of sleep for file recognition ([f4768bf](https://github.com/sasjs/server/commit/f4768bffd3dbb2fe243966572ba74002024d96e1)), closes [#381](https://github.com/sasjs/server/issues/381)
|
|
||||||
|
|
||||||
# [0.39.0](https://github.com/sasjs/server/compare/v0.38.0...v0.39.0) (2024-10-31)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **api:** fixed condition in processProgram ([48a9a4d](https://github.com/sasjs/server/commit/48a9a4dd0e31f84209635382be4ec4bb2c3a9c0c))
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **api:** added session state endpoint ([6b6546c](https://github.com/sasjs/server/commit/6b6546c7ad0833347f8dc4cdba6ad19132f7aaef))
|
|
||||||
|
|
||||||
# [0.38.0](https://github.com/sasjs/server/compare/v0.37.0...v0.38.0) (2024-10-30)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **api:** enabled query params in stp/trigger endpoint ([5cda9cd](https://github.com/sasjs/server/commit/5cda9cd5d8623b7ea2ecd989d7808f47ec866672))
|
|
||||||
|
|
||||||
# [0.37.0](https://github.com/sasjs/server/compare/v0.36.0...v0.37.0) (2024-10-29)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **stp:** added trigger endpoint ([b0723f1](https://github.com/sasjs/server/commit/b0723f14448d60ffce4f2175cf8a73fc4d4dd0ee))
|
|
||||||
|
|
||||||
# [0.36.0](https://github.com/sasjs/server/compare/v0.35.4...v0.36.0) (2024-10-29)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **code:** added code/trigger API endpoint ([ffcf193](https://github.com/sasjs/server/commit/ffcf193b87d811b166d79af74013776a253b50b0))
|
|
||||||
|
|
||||||
## [0.35.4](https://github.com/sasjs/server/compare/v0.35.3...v0.35.4) (2024-01-15)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **api:** fixed env issue in MacOS executable ([73d965d](https://github.com/sasjs/server/commit/73d965daf54b16c0921e4b18d11a1e6f8650884d))
|
|
||||||
|
|
||||||
## [0.35.3](https://github.com/sasjs/server/compare/v0.35.2...v0.35.3) (2023-11-07)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* enable embedded LFs in JS STP vars ([7e8cbbf](https://github.com/sasjs/server/commit/7e8cbbf377b27a7f5dd9af0bc6605c01f302f5d9))
|
|
||||||
|
|
||||||
## [0.35.2](https://github.com/sasjs/server/compare/v0.35.1...v0.35.2) (2023-08-07)
|
## [0.35.2](https://github.com/sasjs/server/compare/v0.35.1...v0.35.2) (2023-08-07)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
|||||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||||
|
|
||||||
#default value is 100
|
#default value is 100
|
||||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||||
|
|
||||||
#default value is 10
|
#default value is 10
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
||||||
|
|||||||
4659
api/package-lock.json
generated
4659
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,10 @@
|
|||||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||||
"exe": "npm run build && pkg .",
|
"exe": "npm run build && pkg .",
|
||||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||||
"public:copy": "cp -r ./public/ ./build/public/",
|
"public:copy": "cpr ./public/ ./build/public/",
|
||||||
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
"sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
|
||||||
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
"sas:copy": "cpr ./sas/ ./build/sas/",
|
||||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
"web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
|
||||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||||
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"express-session": "^1.17.2",
|
"express-session": "^1.17.2",
|
||||||
"helmet": "^5.0.2",
|
"helmet": "^5.0.2",
|
||||||
"joi": "^17.4.2",
|
"joi": "^17.4.2",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"ldapjs": "2.3.3",
|
"ldapjs": "2.3.3",
|
||||||
"mongoose": "^6.0.12",
|
"mongoose": "^6.0.12",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
@@ -87,15 +87,16 @@
|
|||||||
"@types/unzipper": "^0.10.5",
|
"@types/unzipper": "^0.10.5",
|
||||||
"adm-zip": "^0.5.9",
|
"adm-zip": "^0.5.9",
|
||||||
"axios": "0.27.2",
|
"axios": "0.27.2",
|
||||||
|
"cpr": "^3.0.1",
|
||||||
"csrf": "^3.1.0",
|
"csrf": "^3.1.0",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^10.0.0",
|
||||||
"http-headers-validation": "^0.0.1",
|
"http-headers-validation": "^0.0.1",
|
||||||
"jest": "^27.0.6",
|
"jest": "^27.0.6",
|
||||||
"mongodb-memory-server": "8.11.4",
|
"mongodb-memory-server": "8.11.4",
|
||||||
"nodejs-file-downloader": "4.10.2",
|
"nodejs-file-downloader": "4.10.2",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"pkg": "5.6.0",
|
"pkg": "5.6.0",
|
||||||
"prettier": "^2.3.1",
|
"prettier": "^3.0.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"supertest": "^6.1.3",
|
"supertest": "^6.1.3",
|
||||||
"ts-jest": "^27.0.3",
|
"ts-jest": "^27.0.3",
|
||||||
|
|||||||
@@ -98,47 +98,17 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
code:
|
code:
|
||||||
type: string
|
type: string
|
||||||
description: 'The code to be executed'
|
description: 'Code of program'
|
||||||
example: '* Your Code HERE;'
|
example: '* Code HERE;'
|
||||||
runTime:
|
runTime:
|
||||||
$ref: '#/components/schemas/RunTimeType'
|
$ref: '#/components/schemas/RunTimeType'
|
||||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
description: 'runtime for program'
|
||||||
example: js
|
example: js
|
||||||
required:
|
required:
|
||||||
- code
|
- code
|
||||||
- runTime
|
- runTime
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
TriggerCodeResponse:
|
|
||||||
properties:
|
|
||||||
sessionId:
|
|
||||||
type: string
|
|
||||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
|
||||||
example: 20241028074744-54132-1730101664824
|
|
||||||
required:
|
|
||||||
- sessionId
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
TriggerCodePayload:
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
description: 'The code to be executed'
|
|
||||||
example: '* Your Code HERE;'
|
|
||||||
runTime:
|
|
||||||
$ref: '#/components/schemas/RunTimeType'
|
|
||||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
|
||||||
example: sas
|
|
||||||
expiresAfterMins:
|
|
||||||
type: number
|
|
||||||
format: double
|
|
||||||
description: "Amount of minutes after the completion of the job when the session must be\ndestroyed."
|
|
||||||
example: 15
|
|
||||||
required:
|
|
||||||
- code
|
|
||||||
- runTime
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
MemberType.folder:
|
MemberType.folder:
|
||||||
enum:
|
enum:
|
||||||
- folder
|
- folder
|
||||||
@@ -585,14 +555,6 @@ components:
|
|||||||
- needsToUpdatePassword
|
- needsToUpdatePassword
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
SessionState:
|
|
||||||
enum:
|
|
||||||
- initialising
|
|
||||||
- pending
|
|
||||||
- running
|
|
||||||
- completed
|
|
||||||
- failed
|
|
||||||
type: string
|
|
||||||
ExecutePostRequestPayload:
|
ExecutePostRequestPayload:
|
||||||
properties:
|
properties:
|
||||||
_program:
|
_program:
|
||||||
@@ -601,16 +563,6 @@ components:
|
|||||||
example: /Public/somefolder/some.file
|
example: /Public/somefolder/some.file
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
TriggerProgramResponse:
|
|
||||||
properties:
|
|
||||||
sessionId:
|
|
||||||
type: string
|
|
||||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
|
||||||
example: 20241028074744-54132-1730101664824
|
|
||||||
required:
|
|
||||||
- sessionId
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
LoginPayload:
|
LoginPayload:
|
||||||
properties:
|
properties:
|
||||||
username:
|
username:
|
||||||
@@ -853,30 +805,6 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ExecuteCodePayload'
|
$ref: '#/components/schemas/ExecuteCodePayload'
|
||||||
/SASjsApi/code/trigger:
|
|
||||||
post:
|
|
||||||
operationId: TriggerCode
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Ok
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/TriggerCodeResponse'
|
|
||||||
description: 'Trigger Code on the Specified Runtime'
|
|
||||||
summary: 'Triggers code and returns SessionId immediately - does not wait for job completion'
|
|
||||||
tags:
|
|
||||||
- Code
|
|
||||||
security:
|
|
||||||
-
|
|
||||||
bearerAuth: []
|
|
||||||
parameters: []
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/TriggerCodePayload'
|
|
||||||
/SASjsApi/drive/deploy:
|
/SASjsApi/drive/deploy:
|
||||||
post:
|
post:
|
||||||
operationId: Deploy
|
operationId: Deploy
|
||||||
@@ -1849,30 +1777,6 @@ paths:
|
|||||||
-
|
-
|
||||||
bearerAuth: []
|
bearerAuth: []
|
||||||
parameters: []
|
parameters: []
|
||||||
'/SASjsApi/session/{sessionId}/state':
|
|
||||||
get:
|
|
||||||
operationId: SessionState
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Ok
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SessionState'
|
|
||||||
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
|
|
||||||
summary: 'Get session state (initialising, pending, running, completed, failed).'
|
|
||||||
tags:
|
|
||||||
- Session
|
|
||||||
security:
|
|
||||||
-
|
|
||||||
bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
-
|
|
||||||
in: path
|
|
||||||
name: sessionId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
/SASjsApi/stp/execute:
|
/SASjsApi/stp/execute:
|
||||||
get:
|
get:
|
||||||
operationId: ExecuteGetRequest
|
operationId: ExecuteGetRequest
|
||||||
@@ -1894,7 +1798,7 @@ paths:
|
|||||||
bearerAuth: []
|
bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
-
|
-
|
||||||
description: 'Location of Stored Program in SASjs Drive.'
|
description: 'Location of code in SASjs Drive'
|
||||||
in: query
|
in: query
|
||||||
name: _program
|
name: _program
|
||||||
required: true
|
required: true
|
||||||
@@ -1902,7 +1806,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
example: /Projects/myApp/some/program
|
example: /Projects/myApp/some/program
|
||||||
-
|
-
|
||||||
description: 'Optional query param for setting debug mode (returns the session log in the response body).'
|
description: 'Optional query param for setting debug mode, which will return the session log.'
|
||||||
in: query
|
in: query
|
||||||
name: _debug
|
name: _debug
|
||||||
required: false
|
required: false
|
||||||
@@ -1943,50 +1847,6 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
||||||
/SASjsApi/stp/trigger:
|
|
||||||
post:
|
|
||||||
operationId: TriggerProgram
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Ok
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/TriggerProgramResponse'
|
|
||||||
description: 'Trigger Program on the Specified Runtime.'
|
|
||||||
summary: 'Triggers program and returns SessionId immediately - does not wait for program completion.'
|
|
||||||
tags:
|
|
||||||
- STP
|
|
||||||
security:
|
|
||||||
-
|
|
||||||
bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
-
|
|
||||||
description: 'Location of code in SASjs Drive.'
|
|
||||||
in: query
|
|
||||||
name: _program
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
example: /Projects/myApp/some/program
|
|
||||||
-
|
|
||||||
description: 'Optional query param for setting debug mode.'
|
|
||||||
in: query
|
|
||||||
name: _debug
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
format: double
|
|
||||||
type: number
|
|
||||||
example: 131
|
|
||||||
-
|
|
||||||
description: 'Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.'
|
|
||||||
in: query
|
|
||||||
name: expiresAfterMins
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
format: double
|
|
||||||
type: number
|
|
||||||
example: 15
|
|
||||||
/:
|
/:
|
||||||
get:
|
get:
|
||||||
operationId: Home
|
operationId: Home
|
||||||
|
|||||||
@@ -1,57 +1,27 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
||||||
import { ExecutionController, getSessionController } from './internal'
|
import { ExecutionController } from './internal'
|
||||||
import {
|
import {
|
||||||
getPreProgramVariables,
|
getPreProgramVariables,
|
||||||
getUserAutoExec,
|
getUserAutoExec,
|
||||||
ModeType,
|
ModeType,
|
||||||
|
parseLogToArray,
|
||||||
RunTimeType
|
RunTimeType
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
|
||||||
interface ExecuteCodePayload {
|
interface ExecuteCodePayload {
|
||||||
/**
|
/**
|
||||||
* The code to be executed
|
* Code of program
|
||||||
* @example "* Your Code HERE;"
|
* @example "* Code HERE;"
|
||||||
*/
|
*/
|
||||||
code: string
|
code: string
|
||||||
/**
|
/**
|
||||||
* The runtime for the code - eg SAS, JS, PY or R
|
* runtime for program
|
||||||
* @example "js"
|
* @example "js"
|
||||||
*/
|
*/
|
||||||
runTime: RunTimeType
|
runTime: RunTimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TriggerCodePayload {
|
|
||||||
/**
|
|
||||||
* The code to be executed
|
|
||||||
* @example "* Your Code HERE;"
|
|
||||||
*/
|
|
||||||
code: string
|
|
||||||
/**
|
|
||||||
* The runtime for the code - eg SAS, JS, PY or R
|
|
||||||
* @example "sas"
|
|
||||||
*/
|
|
||||||
runTime: RunTimeType
|
|
||||||
/**
|
|
||||||
* Amount of minutes after the completion of the job when the session must be
|
|
||||||
* destroyed.
|
|
||||||
* @example 15
|
|
||||||
*/
|
|
||||||
expiresAfterMins?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TriggerCodeResponse {
|
|
||||||
/**
|
|
||||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
|
||||||
* used to store code outputs.<br><br>
|
|
||||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
|
||||||
* `sessionId` can be used to poll session state using the
|
|
||||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
|
||||||
* @example "20241028074744-54132-1730101664824"
|
|
||||||
*/
|
|
||||||
sessionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/code')
|
@Route('SASjsApi/code')
|
||||||
@Tags('Code')
|
@Tags('Code')
|
||||||
@@ -74,18 +44,6 @@ export class CodeController {
|
|||||||
): Promise<string | Buffer> {
|
): Promise<string | Buffer> {
|
||||||
return executeCode(request, body)
|
return executeCode(request, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger Code on the Specified Runtime
|
|
||||||
* @summary Triggers code and returns SessionId immediately - does not wait for job completion
|
|
||||||
*/
|
|
||||||
@Post('/trigger')
|
|
||||||
public async triggerCode(
|
|
||||||
@Request() request: express.Request,
|
|
||||||
@Body() body: TriggerCodePayload
|
|
||||||
): Promise<TriggerCodeResponse> {
|
|
||||||
return triggerCode(request, body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeCode = async (
|
const executeCode = async (
|
||||||
@@ -118,49 +76,3 @@ const executeCode = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerCode = async (
|
|
||||||
req: express.Request,
|
|
||||||
{ code, runTime, expiresAfterMins }: TriggerCodePayload
|
|
||||||
): Promise<TriggerCodeResponse> => {
|
|
||||||
const { user } = req
|
|
||||||
const userAutoExec =
|
|
||||||
process.env.MODE === ModeType.Server
|
|
||||||
? user?.autoExec
|
|
||||||
: await getUserAutoExec()
|
|
||||||
|
|
||||||
// get session controller based on runTime
|
|
||||||
const sessionController = getSessionController(runTime)
|
|
||||||
|
|
||||||
// get session
|
|
||||||
const session = await sessionController.getSession()
|
|
||||||
|
|
||||||
// add expiresAfterMins to session if provided
|
|
||||||
if (expiresAfterMins) {
|
|
||||||
// expiresAfterMins.used is set initially to false
|
|
||||||
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// call executeProgram method of ExecutionController without awaiting
|
|
||||||
new ExecutionController().executeProgram({
|
|
||||||
program: code,
|
|
||||||
preProgramVariables: getPreProgramVariables(req),
|
|
||||||
vars: { ...req.query, _debug: 131 },
|
|
||||||
otherArgs: { userAutoExec },
|
|
||||||
runTime: runTime,
|
|
||||||
includePrintOutput: true,
|
|
||||||
session // session is provided
|
|
||||||
})
|
|
||||||
|
|
||||||
// return session id
|
|
||||||
return { sessionId: session.id }
|
|
||||||
} catch (err: any) {
|
|
||||||
throw {
|
|
||||||
code: 400,
|
|
||||||
status: 'failure',
|
|
||||||
message: 'Job execution failed.',
|
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from 'path'
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { getSessionController, processProgram } from './'
|
import { getSessionController, processProgram } from './'
|
||||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||||
import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
|
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||||
import {
|
import {
|
||||||
extractHeaders,
|
extractHeaders,
|
||||||
getFilesFolder,
|
getFilesFolder,
|
||||||
@@ -75,7 +75,8 @@ export class ExecutionController {
|
|||||||
|
|
||||||
const session =
|
const session =
|
||||||
sessionByFileUpload ?? (await sessionController.getSession())
|
sessionByFileUpload ?? (await sessionController.getSession())
|
||||||
session.state = SessionState.running
|
session.inUse = true
|
||||||
|
session.consumed = true
|
||||||
|
|
||||||
const logPath = path.join(session.path, 'log.log')
|
const logPath = path.join(session.path, 'log.log')
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
@@ -120,7 +121,7 @@ export class ExecutionController {
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
// it should be deleted by scheduleSessionDestroy
|
// it should be deleted by scheduleSessionDestroy
|
||||||
session.state = SessionState.completed
|
session.inUse = false
|
||||||
|
|
||||||
const resultParts = []
|
const resultParts = []
|
||||||
|
|
||||||
@@ -144,9 +145,7 @@ export class ExecutionController {
|
|||||||
return {
|
return {
|
||||||
httpHeaders,
|
httpHeaders,
|
||||||
result:
|
result:
|
||||||
isDebugOn(vars) || session.failureReason
|
isDebugOn(vars) || session.crashed ? resultParts.join(`\n`) : webout
|
||||||
? resultParts.join(`\n`)
|
|
||||||
: webout
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { Request, RequestHandler } from 'express'
|
|||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import { uuidv4 } from '@sasjs/utils'
|
import { uuidv4 } from '@sasjs/utils'
|
||||||
import { getSessionController } from '.'
|
import { getSessionController } from '.'
|
||||||
import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
|
import {
|
||||||
import { SessionState } from '../../types'
|
executeProgramRawValidation,
|
||||||
|
getRunTimeAndFilePath,
|
||||||
|
RunTimeType
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
export class FileUploadController {
|
export class FileUploadController {
|
||||||
private storage = multer.diskStorage({
|
private storage = multer.diskStorage({
|
||||||
@@ -53,8 +56,9 @@ export class FileUploadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await sessionController.getSession()
|
const session = await sessionController.getSession()
|
||||||
// change session state to 'running', so that it's not available for any other request
|
// marking consumed true, so that it's not available
|
||||||
session.state = SessionState.running
|
// as readySession for any other request
|
||||||
|
session.consumed = true
|
||||||
|
|
||||||
req.sasjsSession = session
|
req.sasjsSession = session
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { Session, SessionState } from '../../types'
|
import { Session } from '../../types'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
createFile,
|
createFile,
|
||||||
fileExists,
|
fileExists,
|
||||||
generateTimestamp,
|
generateTimestamp,
|
||||||
readFile
|
readFile,
|
||||||
|
isWindows
|
||||||
} from '@sasjs/utils'
|
} from '@sasjs/utils'
|
||||||
|
|
||||||
const execFilePromise = promisify(execFile)
|
const execFilePromise = promisify(execFile)
|
||||||
@@ -23,9 +24,7 @@ export class SessionController {
|
|||||||
protected sessions: Session[] = []
|
protected sessions: Session[] = []
|
||||||
|
|
||||||
protected getReadySessions = (): Session[] =>
|
protected getReadySessions = (): Session[] =>
|
||||||
this.sessions.filter(
|
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||||
(session: Session) => session.state === SessionState.pending
|
|
||||||
)
|
|
||||||
|
|
||||||
protected async createSession(): Promise<Session> {
|
protected async createSession(): Promise<Session> {
|
||||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||||
@@ -41,18 +40,19 @@ export class SessionController {
|
|||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
state: SessionState.pending,
|
ready: true,
|
||||||
|
inUse: true,
|
||||||
|
consumed: false,
|
||||||
|
completed: false,
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
|
||||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||||
|
|
||||||
this.sessions.push(session)
|
this.sessions.push(session)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +67,6 @@ export class SessionController {
|
|||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessionById(id: string) {
|
|
||||||
return this.sessions.find((session) => session.id === id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SASSessionController extends SessionController {
|
export class SASSessionController extends SessionController {
|
||||||
@@ -88,7 +84,10 @@ export class SASSessionController extends SessionController {
|
|||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
state: SessionState.initialising,
|
ready: false,
|
||||||
|
inUse: false,
|
||||||
|
consumed: false,
|
||||||
|
completed: false,
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
@@ -146,20 +145,13 @@ ${autoExecContent}`
|
|||||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||||
])
|
])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
session.state = SessionState.completed
|
session.completed = true
|
||||||
|
|
||||||
process.logger.info('session completed', session)
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
session.state = SessionState.failed
|
session.completed = true
|
||||||
|
session.crashed = err.toString()
|
||||||
session.failureReason = err.toString()
|
process.logger.error('session crashed', session.id, session.crashed)
|
||||||
|
|
||||||
process.logger.error(
|
|
||||||
'session crashed',
|
|
||||||
session.id,
|
|
||||||
session.failureReason
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// we have a triggered session - add to array
|
// we have a triggered session - add to array
|
||||||
@@ -176,19 +168,15 @@ ${autoExecContent}`
|
|||||||
const codeFilePath = path.join(session.path, 'code.sas')
|
const codeFilePath = path.join(session.path, 'code.sas')
|
||||||
|
|
||||||
// TODO: don't wait forever
|
// TODO: don't wait forever
|
||||||
while (
|
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||||
(await fileExists(codeFilePath)) &&
|
|
||||||
session.state !== SessionState.failed
|
|
||||||
) {}
|
|
||||||
|
|
||||||
if (session.state === SessionState.failed) {
|
if (session.crashed)
|
||||||
process.logger.error(
|
process.logger.error(
|
||||||
'session crashed! while waiting to be ready',
|
'session crashed! while waiting to be ready',
|
||||||
session.failureReason
|
session.crashed
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
session.state = SessionState.pending
|
session.ready = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteSession(session: Session) {
|
private async deleteSession(session: Session) {
|
||||||
@@ -204,31 +192,14 @@ ${autoExecContent}`
|
|||||||
private scheduleSessionDestroy(session: Session) {
|
private scheduleSessionDestroy(session: Session) {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
async () => {
|
async () => {
|
||||||
if (session.state === SessionState.running) {
|
if (session.inUse) {
|
||||||
// adding 10 more minutes
|
// adding 10 more minutes
|
||||||
const newDeathTimeStamp =
|
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
|
||||||
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
|
|
||||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||||
|
|
||||||
this.scheduleSessionDestroy(session)
|
this.scheduleSessionDestroy(session)
|
||||||
} else {
|
} else {
|
||||||
const { expiresAfterMins } = session
|
await this.deleteSession(session)
|
||||||
|
|
||||||
// delay session destroy if expiresAfterMins present
|
|
||||||
if (expiresAfterMins && session.state !== SessionState.completed) {
|
|
||||||
// calculate session death time using expiresAfterMins
|
|
||||||
const newDeathTimeStamp =
|
|
||||||
parseInt(session.deathTimeStamp) +
|
|
||||||
expiresAfterMins.mins * 60 * 1000
|
|
||||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
|
||||||
|
|
||||||
// set expiresAfterMins to true to avoid using it again
|
|
||||||
session.expiresAfterMins!.used = true
|
|
||||||
|
|
||||||
this.scheduleSessionDestroy(session)
|
|
||||||
} else {
|
|
||||||
await this.deleteSession(session)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
|
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
|
||||||
@@ -260,16 +231,9 @@ data _null_;
|
|||||||
rc=filename(fname,getoption('SYSIN') );
|
rc=filename(fname,getoption('SYSIN') );
|
||||||
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
||||||
rc=filename(fname);
|
rc=filename(fname);
|
||||||
/* now wait for the real SYSIN (location of code.sas) */
|
/* now wait for the real SYSIN */
|
||||||
slept=0;fname='';
|
slept=0;
|
||||||
do until (slept>(60*15));
|
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
|
||||||
rc=filename(fname,getoption('SYSIN'));
|
|
||||||
if rc = 0 and fexist(fname) then do;
|
|
||||||
putlog fname=;
|
|
||||||
rc=filename(fname);
|
|
||||||
rc=sleep(0.01,1); /* wait just a little more */
|
|
||||||
stop;
|
|
||||||
end;
|
|
||||||
slept=slept+sleep(0.01,1);
|
slept=slept+sleep(0.01,1);
|
||||||
end;
|
end;
|
||||||
stop;
|
stop;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const createJSProgram = async (
|
|||||||
) => {
|
) => {
|
||||||
const varStatments = Object.keys(vars).reduce(
|
const varStatments = Object.keys(vars).reduce(
|
||||||
(computed: string, key: string) =>
|
(computed: string, key: string) =>
|
||||||
`${computed}const ${key} = \`${vars[key]}\`;\n`,
|
`${computed}const ${key} = '${vars[key]}';\n`,
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { once } from 'stream'
|
import { once } from 'stream'
|
||||||
import { createFile, moveFile } from '@sasjs/utils'
|
import { createFile, moveFile } from '@sasjs/utils'
|
||||||
import { PreProgramVars, Session, SessionState } from '../../types'
|
import { PreProgramVars, Session } from '../../types'
|
||||||
import { RunTimeType } from '../../utils'
|
import { RunTimeType } from '../../utils'
|
||||||
import {
|
import {
|
||||||
ExecutionVars,
|
ExecutionVars,
|
||||||
@@ -49,7 +49,7 @@ export const processProgram = async (
|
|||||||
await moveFile(codePath + '.bkp', codePath)
|
await moveFile(codePath + '.bkp', codePath)
|
||||||
|
|
||||||
// we now need to poll the session status
|
// we now need to poll the session status
|
||||||
while (session.state !== SessionState.completed) {
|
while (!session.completed) {
|
||||||
await delay(50)
|
await delay(50)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -114,20 +114,13 @@ export const processProgram = async (
|
|||||||
|
|
||||||
await execFilePromise(executablePath, [codePath], writeStream)
|
await execFilePromise(executablePath, [codePath], writeStream)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
session.state = SessionState.completed
|
session.completed = true
|
||||||
|
|
||||||
process.logger.info('session completed', session)
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
session.state = SessionState.failed
|
session.completed = true
|
||||||
|
session.crashed = err.toString()
|
||||||
session.failureReason = err.toString()
|
process.logger.error('session crashed', session.id, session.crashed)
|
||||||
|
|
||||||
process.logger.error(
|
|
||||||
'session crashed',
|
|
||||||
session.id,
|
|
||||||
session.failureReason
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// copy the code file to log and end write stream
|
// copy the code file to log and end write stream
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||||
import { UserResponse } from './user'
|
import { UserResponse } from './user'
|
||||||
import { getSessionController } from './internal'
|
|
||||||
import { SessionState } from '../types'
|
|
||||||
|
|
||||||
interface SessionResponse extends UserResponse {
|
interface SessionResponse extends UserResponse {
|
||||||
needsToUpdatePassword: boolean
|
needsToUpdatePassword: boolean
|
||||||
@@ -28,18 +26,6 @@ export class SessionController {
|
|||||||
): Promise<SessionResponse> {
|
): Promise<SessionResponse> {
|
||||||
return session(request)
|
return session(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The polling endpoint is currently implemented for single-server deployments only.<br>
|
|
||||||
* Load balanced / grid topologies will be supported in a future release.<br>
|
|
||||||
* If your site requires this, please reach out to SASjs Support.
|
|
||||||
* @summary Get session state (initialising, pending, running, completed, failed).
|
|
||||||
* @example completed
|
|
||||||
*/
|
|
||||||
@Get('/:sessionId/state')
|
|
||||||
public async sessionState(sessionId: string): Promise<SessionState> {
|
|
||||||
return sessionState(sessionId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = (req: express.Request) => ({
|
const session = (req: express.Request) => ({
|
||||||
@@ -49,23 +35,3 @@ const session = (req: express.Request) => ({
|
|||||||
isAdmin: req.user!.isAdmin,
|
isAdmin: req.user!.isAdmin,
|
||||||
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionState = (sessionId: string): SessionState => {
|
|
||||||
for (let runTime of process.runTimes) {
|
|
||||||
// get session controller for each available runTime
|
|
||||||
const sessionController = getSessionController(runTime)
|
|
||||||
|
|
||||||
// get session by sessionId
|
|
||||||
const session = sessionController.getSessionById(sessionId)
|
|
||||||
|
|
||||||
// return session state if session was found
|
|
||||||
if (session) {
|
|
||||||
return session.state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw {
|
|
||||||
code: 404,
|
|
||||||
message: `Session with ID '${sessionId}' was not found.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||||
import {
|
import { ExecutionController, ExecutionVars } from './internal'
|
||||||
ExecutionController,
|
|
||||||
ExecutionVars,
|
|
||||||
getSessionController
|
|
||||||
} from './internal'
|
|
||||||
import {
|
import {
|
||||||
getPreProgramVariables,
|
getPreProgramVariables,
|
||||||
makeFilesNamesMap,
|
makeFilesNamesMap,
|
||||||
getRunTimeAndFilePath
|
getRunTimeAndFilePath
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { MulterFile } from '../types/Upload'
|
import { MulterFile } from '../types/Upload'
|
||||||
|
import { debug } from 'console'
|
||||||
|
|
||||||
interface ExecutePostRequestPayload {
|
interface ExecutePostRequestPayload {
|
||||||
/**
|
/**
|
||||||
@@ -20,36 +17,6 @@ interface ExecutePostRequestPayload {
|
|||||||
_program?: string
|
_program?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TriggerProgramPayload {
|
|
||||||
/**
|
|
||||||
* Location of SAS program.
|
|
||||||
* @example "/Public/somefolder/some.file"
|
|
||||||
*/
|
|
||||||
_program: string
|
|
||||||
/**
|
|
||||||
* Amount of minutes after the completion of the program when the session must be
|
|
||||||
* destroyed.
|
|
||||||
* @example 15
|
|
||||||
*/
|
|
||||||
expiresAfterMins?: number
|
|
||||||
/**
|
|
||||||
* Query param for setting debug mode.
|
|
||||||
*/
|
|
||||||
_debug?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TriggerProgramResponse {
|
|
||||||
/**
|
|
||||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
|
||||||
* used to store program outputs.<br><br>
|
|
||||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
|
||||||
* `sessionId` can be used to poll session state using the
|
|
||||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
|
||||||
* @example "20241028074744-54132-1730101664824"
|
|
||||||
*/
|
|
||||||
sessionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/stp')
|
@Route('SASjsApi/stp')
|
||||||
@Tags('STP')
|
@Tags('STP')
|
||||||
@@ -63,8 +30,8 @@ export class STPController {
|
|||||||
* https://server.sasjs.io/storedprograms
|
* https://server.sasjs.io/storedprograms
|
||||||
*
|
*
|
||||||
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||||
* @param _program Location of Stored Program in SASjs Drive.
|
* @param _program Location of code in SASjs Drive
|
||||||
* @param _debug Optional query param for setting debug mode (returns the session log in the response body).
|
* @param _debug Optional query param for setting debug mode, which will return the session log.
|
||||||
* @example _program "/Projects/myApp/some/program"
|
* @example _program "/Projects/myApp/some/program"
|
||||||
* @example _debug 131
|
* @example _debug 131
|
||||||
*/
|
*/
|
||||||
@@ -112,26 +79,6 @@ export class STPController {
|
|||||||
|
|
||||||
return execute(request, program!, vars, otherArgs)
|
return execute(request, program!, vars, otherArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger Program on the Specified Runtime.
|
|
||||||
* @summary Triggers program and returns SessionId immediately - does not wait for program completion.
|
|
||||||
* @param _program Location of code in SASjs Drive.
|
|
||||||
* @param expiresAfterMins Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.
|
|
||||||
* @param _debug Optional query param for setting debug mode.
|
|
||||||
* @example _program "/Projects/myApp/some/program"
|
|
||||||
* @example _debug 131
|
|
||||||
* @example expiresAfterMins 15
|
|
||||||
*/
|
|
||||||
@Post('/trigger')
|
|
||||||
public async triggerProgram(
|
|
||||||
@Request() request: express.Request,
|
|
||||||
@Query() _program: string,
|
|
||||||
@Query() _debug?: number,
|
|
||||||
@Query() expiresAfterMins?: number
|
|
||||||
): Promise<TriggerProgramResponse> {
|
|
||||||
return triggerProgram(request, { _program, _debug, expiresAfterMins })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const execute = async (
|
const execute = async (
|
||||||
@@ -170,52 +117,3 @@ const execute = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerProgram = async (
|
|
||||||
req: express.Request,
|
|
||||||
{ _program, _debug, expiresAfterMins }: TriggerProgramPayload
|
|
||||||
): Promise<TriggerProgramResponse> => {
|
|
||||||
try {
|
|
||||||
// put _program query param into vars object
|
|
||||||
const vars: { [key: string]: string | number } = { _program }
|
|
||||||
|
|
||||||
// if present add _debug query param to vars object
|
|
||||||
if (_debug) {
|
|
||||||
vars._debug = _debug
|
|
||||||
}
|
|
||||||
|
|
||||||
// get code path and runTime
|
|
||||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
|
||||||
|
|
||||||
// get session controller based on runTime
|
|
||||||
const sessionController = getSessionController(runTime)
|
|
||||||
|
|
||||||
// get session
|
|
||||||
const session = await sessionController.getSession()
|
|
||||||
|
|
||||||
// add expiresAfterMins to session if provided
|
|
||||||
if (expiresAfterMins) {
|
|
||||||
// expiresAfterMins.used is set initially to false
|
|
||||||
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// call executeFile method of ExecutionController without awaiting
|
|
||||||
new ExecutionController().executeFile({
|
|
||||||
programPath: codePath,
|
|
||||||
runTime,
|
|
||||||
preProgramVariables: getPreProgramVariables(req),
|
|
||||||
vars,
|
|
||||||
session
|
|
||||||
})
|
|
||||||
|
|
||||||
// return session id
|
|
||||||
return { sessionId: session.id }
|
|
||||||
} catch (err: any) {
|
|
||||||
throw {
|
|
||||||
code: 400,
|
|
||||||
status: 'failure',
|
|
||||||
message: 'Job execution failed.',
|
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ const getUser = async (
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
|
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
|
||||||
groups: user.groups
|
groups: user.groups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { runCodeValidation, triggerCodeValidation } from '../../utils'
|
import { runCodeValidation } from '../../utils'
|
||||||
import { CodeController } from '../../controllers/'
|
import { CodeController } from '../../controllers/'
|
||||||
|
|
||||||
const runRouter = express.Router()
|
const runRouter = express.Router()
|
||||||
@@ -28,22 +28,4 @@ runRouter.post('/execute', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
runRouter.post('/trigger', async (req, res) => {
|
|
||||||
const { error, value: body } = triggerCodeValidation(req.body)
|
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await controller.triggerCode(req, body)
|
|
||||||
|
|
||||||
res.status(200)
|
|
||||||
res.send(response)
|
|
||||||
} catch (err: any) {
|
|
||||||
const statusCode = err.code
|
|
||||||
|
|
||||||
delete err.code
|
|
||||||
|
|
||||||
res.status(statusCode).send(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default runRouter
|
export default runRouter
|
||||||
|
|||||||
@@ -1,37 +1,16 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { SessionController } from '../../controllers'
|
import { SessionController } from '../../controllers'
|
||||||
import { sessionIdValidation } from '../../utils'
|
|
||||||
|
|
||||||
const sessionRouter = express.Router()
|
const sessionRouter = express.Router()
|
||||||
|
|
||||||
const controller = new SessionController()
|
|
||||||
|
|
||||||
sessionRouter.get('/', async (req, res) => {
|
sessionRouter.get('/', async (req, res) => {
|
||||||
|
const controller = new SessionController()
|
||||||
try {
|
try {
|
||||||
const response = await controller.session(req)
|
const response = await controller.session(req)
|
||||||
|
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(403).send(err.toString())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sessionRouter.get('/:sessionId/state', async (req, res) => {
|
|
||||||
const { error, value: params } = sessionIdValidation(req.params)
|
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await controller.sessionState(params.sessionId)
|
|
||||||
|
|
||||||
res.status(200)
|
|
||||||
res.send(response)
|
|
||||||
} catch (err: any) {
|
|
||||||
const statusCode = err.code
|
|
||||||
|
|
||||||
delete err.code
|
|
||||||
|
|
||||||
res.status(statusCode).send(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default sessionRouter
|
export default sessionRouter
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
SASSessionController
|
SASSessionController
|
||||||
} from '../../../controllers/internal'
|
} from '../../../controllers/internal'
|
||||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||||
import { Session, SessionState } from '../../../types'
|
import { Session } from '../../../types'
|
||||||
|
|
||||||
const clientId = 'someclientID'
|
const clientId = 'someclientID'
|
||||||
|
|
||||||
@@ -493,7 +493,10 @@ const mockedGetSession = async () => {
|
|||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
state: SessionState.pending,
|
ready: true,
|
||||||
|
inUse: true,
|
||||||
|
consumed: false,
|
||||||
|
completed: false,
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import {
|
import { executeProgramRawValidation } from '../../utils'
|
||||||
executeProgramRawValidation,
|
|
||||||
triggerProgramValidation
|
|
||||||
} from '../../utils'
|
|
||||||
import { STPController } from '../../controllers/'
|
import { STPController } from '../../controllers/'
|
||||||
import { FileUploadController } from '../../controllers/internal'
|
import { FileUploadController } from '../../controllers/internal'
|
||||||
|
|
||||||
@@ -72,28 +69,4 @@ stpRouter.post(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
stpRouter.post('/trigger', async (req, res) => {
|
|
||||||
const { error, value: query } = triggerProgramValidation(req.query)
|
|
||||||
|
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await controller.triggerProgram(
|
|
||||||
req,
|
|
||||||
query._program,
|
|
||||||
query._debug,
|
|
||||||
query.expiresAfterMins
|
|
||||||
)
|
|
||||||
|
|
||||||
res.status(200)
|
|
||||||
res.send(response)
|
|
||||||
} catch (err: any) {
|
|
||||||
const statusCode = err.code
|
|
||||||
|
|
||||||
delete err.code
|
|
||||||
|
|
||||||
res.status(statusCode).send(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default stpRouter
|
export default stpRouter
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
export enum SessionState {
|
|
||||||
initialising = 'initialising', // session is initialising and not ready to be used yet
|
|
||||||
pending = 'pending', // session is ready to be used
|
|
||||||
running = 'running', // session is in use
|
|
||||||
completed = 'completed', // session is completed and can be destroyed
|
|
||||||
failed = 'failed' // session failed
|
|
||||||
}
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string
|
id: string
|
||||||
state: SessionState
|
ready: boolean
|
||||||
creationTimeStamp: string
|
creationTimeStamp: string
|
||||||
deathTimeStamp: string
|
deathTimeStamp: string
|
||||||
path: string
|
path: string
|
||||||
expiresAfterMins?: { mins: number; used: boolean }
|
inUse: boolean
|
||||||
failureReason?: string
|
consumed: boolean
|
||||||
|
completed: boolean
|
||||||
|
crashed?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,9 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import {
|
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
|
||||||
createFolder,
|
|
||||||
getAbsolutePath,
|
|
||||||
getRealPath,
|
|
||||||
fileExists
|
|
||||||
} from '@sasjs/utils'
|
|
||||||
import dotenv from 'dotenv'
|
|
||||||
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
||||||
|
|
||||||
export const setProcessVariables = async () => {
|
export const setProcessVariables = async () => {
|
||||||
const { execPath } = process
|
|
||||||
|
|
||||||
// Check if execPath ends with 'api-macos' to determine executable for MacOS.
|
|
||||||
// This is needed to fix picking .env file issue in MacOS executable.
|
|
||||||
if (execPath) {
|
|
||||||
const envPathSplitted = execPath.split(path.sep)
|
|
||||||
|
|
||||||
if (envPathSplitted.pop() === 'api-macos') {
|
|
||||||
const envPath = path.join(envPathSplitted.join(path.sep), '.env')
|
|
||||||
|
|
||||||
// Override environment variables from envPath if file exists
|
|
||||||
if (await fileExists(envPath)) {
|
|
||||||
dotenv.config({ path: envPath, override: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { MODE, RUN_TIMES } = process.env
|
const { MODE, RUN_TIMES } = process.env
|
||||||
|
|
||||||
if (MODE === ModeType.Server) {
|
if (MODE === ModeType.Server) {
|
||||||
@@ -43,7 +21,6 @@ export const setProcessVariables = async () => {
|
|||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
||||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +41,7 @@ export const setProcessVariables = async () => {
|
|||||||
|
|
||||||
const { SASJS_ROOT } = process.env
|
const { SASJS_ROOT } = process.env
|
||||||
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
||||||
|
|
||||||
await createFolder(absPath)
|
await createFolder(absPath)
|
||||||
|
|
||||||
process.sasjsRoot = getRealPath(absPath)
|
process.sasjsRoot = getRealPath(absPath)
|
||||||
|
|
||||||
const { DRIVE_LOCATION } = process.env
|
const { DRIVE_LOCATION } = process.env
|
||||||
@@ -74,7 +49,6 @@ export const setProcessVariables = async () => {
|
|||||||
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
||||||
process.cwd()
|
process.cwd()
|
||||||
)
|
)
|
||||||
|
|
||||||
await createFolder(absDrivePath)
|
await createFolder(absDrivePath)
|
||||||
process.driveLoc = getRealPath(absDrivePath)
|
process.driveLoc = getRealPath(absDrivePath)
|
||||||
|
|
||||||
@@ -83,9 +57,7 @@ export const setProcessVariables = async () => {
|
|||||||
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
||||||
process.cwd()
|
process.cwd()
|
||||||
)
|
)
|
||||||
|
|
||||||
await createFolder(absLogsPath)
|
await createFolder(absLogsPath)
|
||||||
|
|
||||||
process.logsLoc = getRealPath(absLogsPath)
|
process.logsLoc = getRealPath(absLogsPath)
|
||||||
|
|
||||||
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
|||||||
@@ -178,13 +178,6 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
|
|||||||
runTime: Joi.string().valid(...process.runTimes)
|
runTime: Joi.string().valid(...process.runTimes)
|
||||||
}).validate(data)
|
}).validate(data)
|
||||||
|
|
||||||
export const triggerCodeValidation = (data: any): Joi.ValidationResult =>
|
|
||||||
Joi.object({
|
|
||||||
code: Joi.string().required(),
|
|
||||||
runTime: Joi.string().valid(...process.runTimes),
|
|
||||||
expiresAfterMins: Joi.number().greater(0)
|
|
||||||
}).validate(data)
|
|
||||||
|
|
||||||
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||||
Joi.object({
|
Joi.object({
|
||||||
_program: Joi.string().required(),
|
_program: Joi.string().required(),
|
||||||
@@ -192,17 +185,3 @@ export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
|||||||
})
|
})
|
||||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||||
.validate(data)
|
.validate(data)
|
||||||
|
|
||||||
export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
|
|
||||||
Joi.object({
|
|
||||||
_program: Joi.string().required(),
|
|
||||||
_debug: Joi.number(),
|
|
||||||
expiresAfterMins: Joi.number().greater(0)
|
|
||||||
})
|
|
||||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
|
||||||
.validate(data)
|
|
||||||
|
|
||||||
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
|
|
||||||
Joi.object({
|
|
||||||
sessionId: Joi.string().required()
|
|
||||||
}).validate(data)
|
|
||||||
|
|||||||
21
web/package-lock.json
generated
21
web/package-lock.json
generated
@@ -56,7 +56,7 @@
|
|||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"html-webpack-plugin": "5.5.0",
|
"html-webpack-plugin": "5.5.0",
|
||||||
"path": "0.12.7",
|
"path": "0.12.7",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^3.0.3",
|
||||||
"sass": "^1.44.0",
|
"sass": "^1.44.0",
|
||||||
"sass-loader": "^12.3.0",
|
"sass-loader": "^12.3.0",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
@@ -9413,15 +9413,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "2.4.1",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
|
||||||
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin-prettier.js"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pretty-error": {
|
"node_modules/pretty-error": {
|
||||||
@@ -18615,9 +18618,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"version": "2.4.1",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
|
||||||
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"pretty-error": {
|
"pretty-error": {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
||||||
"build": "webpack --config webpack.prod.ts"
|
"build": "webpack --config webpack.prod.ts",
|
||||||
|
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||||
|
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.4.1",
|
"@emotion/react": "^11.4.1",
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"html-webpack-plugin": "5.5.0",
|
"html-webpack-plugin": "5.5.0",
|
||||||
"path": "0.12.7",
|
"path": "0.12.7",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^3.0.3",
|
||||||
"sass": "^1.44.0",
|
"sass": "^1.44.0",
|
||||||
"sass-loader": "^12.3.0",
|
"sass-loader": "^12.3.0",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family:
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family:
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
Reference in New Issue
Block a user