first commit
This commit is contained in:
7
aws_sigma_service/.bash_logout
Normal file
7
aws_sigma_service/.bash_logout
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# ~/.bash_logout: executed by bash(1) when login shell exits.
|
||||||
|
|
||||||
|
# when leaving the console clear the screen to increase privacy
|
||||||
|
|
||||||
|
if [ "$SHLVL" = 1 ]; then
|
||||||
|
[ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q
|
||||||
|
fi
|
||||||
117
aws_sigma_service/.bashrc
Normal file
117
aws_sigma_service/.bashrc
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# ~/.bashrc: executed by bash(1) for non-login shells.
|
||||||
|
# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
|
||||||
|
# for examples
|
||||||
|
|
||||||
|
# If not running interactively, don't do anything
|
||||||
|
case $- in
|
||||||
|
*i*) ;;
|
||||||
|
*) return;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# don't put duplicate lines or lines starting with space in the history.
|
||||||
|
# See bash(1) for more options
|
||||||
|
HISTCONTROL=ignoreboth
|
||||||
|
|
||||||
|
# append to the history file, don't overwrite it
|
||||||
|
shopt -s histappend
|
||||||
|
|
||||||
|
# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)
|
||||||
|
HISTSIZE=1000
|
||||||
|
HISTFILESIZE=2000
|
||||||
|
|
||||||
|
# check the window size after each command and, if necessary,
|
||||||
|
# update the values of LINES and COLUMNS.
|
||||||
|
shopt -s checkwinsize
|
||||||
|
|
||||||
|
# If set, the pattern "**" used in a pathname expansion context will
|
||||||
|
# match all files and zero or more directories and subdirectories.
|
||||||
|
#shopt -s globstar
|
||||||
|
|
||||||
|
# make less more friendly for non-text input files, see lesspipe(1)
|
||||||
|
[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
|
||||||
|
|
||||||
|
# set variable identifying the chroot you work in (used in the prompt below)
|
||||||
|
if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then
|
||||||
|
debian_chroot=$(cat /etc/debian_chroot)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set a fancy prompt (non-color, unless we know we "want" color)
|
||||||
|
case "$TERM" in
|
||||||
|
xterm-color|*-256color) color_prompt=yes;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# uncomment for a colored prompt, if the terminal has the capability; turned
|
||||||
|
# off by default to not distract the user: the focus in a terminal window
|
||||||
|
# should be on the output of commands, not on the prompt
|
||||||
|
#force_color_prompt=yes
|
||||||
|
|
||||||
|
if [ -n "$force_color_prompt" ]; then
|
||||||
|
if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then
|
||||||
|
# We have color support; assume it's compliant with Ecma-48
|
||||||
|
# (ISO/IEC-6429). (Lack of such support is extremely rare, and such
|
||||||
|
# a case would tend to support setf rather than setaf.)
|
||||||
|
color_prompt=yes
|
||||||
|
else
|
||||||
|
color_prompt=
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$color_prompt" = yes ]; then
|
||||||
|
PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
|
||||||
|
else
|
||||||
|
PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '
|
||||||
|
fi
|
||||||
|
unset color_prompt force_color_prompt
|
||||||
|
|
||||||
|
# If this is an xterm set the title to user@host:dir
|
||||||
|
case "$TERM" in
|
||||||
|
xterm*|rxvt*)
|
||||||
|
PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# enable color support of ls and also add handy aliases
|
||||||
|
if [ -x /usr/bin/dircolors ]; then
|
||||||
|
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
|
||||||
|
alias ls='ls --color=auto'
|
||||||
|
#alias dir='dir --color=auto'
|
||||||
|
#alias vdir='vdir --color=auto'
|
||||||
|
|
||||||
|
alias grep='grep --color=auto'
|
||||||
|
alias fgrep='fgrep --color=auto'
|
||||||
|
alias egrep='egrep --color=auto'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# colored GCC warnings and errors
|
||||||
|
#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
|
||||||
|
|
||||||
|
# some more ls aliases
|
||||||
|
alias ll='ls -alF'
|
||||||
|
alias la='ls -A'
|
||||||
|
alias l='ls -CF'
|
||||||
|
|
||||||
|
# Add an "alert" alias for long running commands. Use like so:
|
||||||
|
# sleep 10; alert
|
||||||
|
alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"'
|
||||||
|
|
||||||
|
# Alias definitions.
|
||||||
|
# You may want to put all your additions into a separate file like
|
||||||
|
# ~/.bash_aliases, instead of adding them here directly.
|
||||||
|
# See /usr/share/doc/bash-doc/examples in the bash-doc package.
|
||||||
|
|
||||||
|
if [ -f ~/.bash_aliases ]; then
|
||||||
|
. ~/.bash_aliases
|
||||||
|
fi
|
||||||
|
|
||||||
|
# enable programmable completion features (you don't need to enable
|
||||||
|
# this, if it's already enabled in /etc/bash.bashrc and /etc/profile
|
||||||
|
# sources /etc/bash.bashrc).
|
||||||
|
if ! shopt -oq posix; then
|
||||||
|
if [ -f /usr/share/bash-completion/bash_completion ]; then
|
||||||
|
. /usr/share/bash-completion/bash_completion
|
||||||
|
elif [ -f /etc/bash_completion ]; then
|
||||||
|
. /etc/bash_completion
|
||||||
|
fi
|
||||||
|
fi
|
||||||
8
aws_sigma_service/.dockerignore
Normal file
8
aws_sigma_service/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
**/dist/
|
||||||
|
**/node_modules/
|
||||||
|
**/target/
|
||||||
|
**/.parcel-cache/
|
||||||
|
**/__pycache__/
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/.git/
|
||||||
5
aws_sigma_service/.gitignore
vendored
Normal file
5
aws_sigma_service/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
target/
|
||||||
|
.parcel-cache/
|
||||||
|
__pycache__/
|
||||||
27
aws_sigma_service/.profile
Normal file
27
aws_sigma_service/.profile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# ~/.profile: executed by the command interpreter for login shells.
|
||||||
|
# This file is not read by bash(1), if ~/.bash_profile or ~/.bash_login
|
||||||
|
# exists.
|
||||||
|
# see /usr/share/doc/bash/examples/startup-files for examples.
|
||||||
|
# the files are located in the bash-doc package.
|
||||||
|
|
||||||
|
# the default umask is set in /etc/profile; for setting the umask
|
||||||
|
# for ssh logins, install and configure the libpam-umask package.
|
||||||
|
#umask 022
|
||||||
|
|
||||||
|
# if running bash
|
||||||
|
if [ -n "$BASH_VERSION" ]; then
|
||||||
|
# include .bashrc if it exists
|
||||||
|
if [ -f "$HOME/.bashrc" ]; then
|
||||||
|
. "$HOME/.bashrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set PATH so it includes user's private bin if it exists
|
||||||
|
if [ -d "$HOME/bin" ] ; then
|
||||||
|
PATH="$HOME/bin:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set PATH so it includes user's private bin if it exists
|
||||||
|
if [ -d "$HOME/.local/bin" ] ; then
|
||||||
|
PATH="$HOME/.local/bin:$PATH"
|
||||||
|
fi
|
||||||
BIN
aws_sigma_service/.yarn/install-state.gz
Normal file
BIN
aws_sigma_service/.yarn/install-state.gz
Normal file
Binary file not shown.
942
aws_sigma_service/.yarn/releases/yarn-4.9.2.cjs
vendored
Executable file
942
aws_sigma_service/.yarn/releases/yarn-4.9.2.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
3
aws_sigma_service/.yarnrc.yml
Normal file
3
aws_sigma_service/.yarnrc.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||||
32
aws_sigma_service/Dockerfile.api
Normal file
32
aws_sigma_service/Dockerfile.api
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM rust:1.91.1-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY executor /app/
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM node:25-alpine
|
||||||
|
|
||||||
|
RUN mkdir /etc/sigma && head -c 16 /dev/random | xxd -p > /etc/sigma/jwt.secret
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
|
COPY .yarn/ ./.yarn/
|
||||||
|
COPY api/package.json ./api/package.json
|
||||||
|
COPY common/package.json ./common/package.json
|
||||||
|
|
||||||
|
RUN yarn workspaces focus @sigma/api
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cd /app/common && yarn build
|
||||||
|
RUN cd /app/api && yarn build
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/executor /usr/local/bin/executor
|
||||||
|
ENV EXECUTOR_BIN_PATH=/usr/local/bin/executor
|
||||||
|
|
||||||
|
WORKDIR /app/api
|
||||||
|
CMD ["sh", "-c", "JWT_SECRET=$(cat /etc/sigma/jwt.secret) exec yarn start"]
|
||||||
20
aws_sigma_service/Dockerfile.nginx
Normal file
20
aws_sigma_service/Dockerfile.nginx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
|
COPY .yarn/ ./.yarn/
|
||||||
|
COPY panel/package.json ./panel/package.json
|
||||||
|
COPY api/package.json ./api/package.json
|
||||||
|
COPY common/package.json ./common/package.json
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cd /app/common && yarn build
|
||||||
|
RUN cd /app/panel && yarn build
|
||||||
|
|
||||||
|
FROM nginx:1.29.3-alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/panel/dist /usr/share/nginx/html
|
||||||
19
aws_sigma_service/api/nest-cli.json
Normal file
19
aws_sigma_service/api/nest-cli.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"monorepo": false,
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"entryFile": "main",
|
||||||
|
"language": "ts",
|
||||||
|
"generateOptions": {
|
||||||
|
"spec": false
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "./tsconfig.build.json",
|
||||||
|
"webpack": false,
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": [],
|
||||||
|
"watchAssets": false,
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
|
}
|
||||||
42
aws_sigma_service/api/package.json
Normal file
42
aws_sigma_service/api/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@sigma/api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/main",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start": "node .",
|
||||||
|
"format": "prettier -w src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.1.9",
|
||||||
|
"@nestjs/core": "^11.1.9",
|
||||||
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/mapped-types": "*",
|
||||||
|
"@nestjs/mongoose": "^11.0.3",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.1.9",
|
||||||
|
"@sigma/common": "*",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"mongoose": "^8.20.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.12",
|
||||||
|
"@nestjs/schematics": "^11.0.9",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/passport": "^0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
aws_sigma_service/api/src/app.module.ts
Normal file
66
aws_sigma_service/api/src/app.module.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProjectsController } from "./controllers/projects.controller.js";
|
||||||
|
import { FunctionsController } from "./controllers/functions.controller.js";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { AuthService } from "./services/auth.service.js";
|
||||||
|
import { UsersService } from "./services/users.service.js";
|
||||||
|
import { User, UserSchema } from "./schemas/user.schema.js";
|
||||||
|
import { Project, ProjectSchema } from "./schemas/project.schema.js";
|
||||||
|
import { Function, FunctionSchema } from "./schemas/function.schema.js";
|
||||||
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { BcryptService } from "./services/bcrypt.service.js";
|
||||||
|
import { JWT_SECRET } from "./auth/const.js";
|
||||||
|
import { AuthController } from "./controllers/auth.controller.js";
|
||||||
|
import { JwtStrategy } from "./auth/jwt.strategy.js";
|
||||||
|
import { LocalStrategy } from "./auth/local.strategy.js";
|
||||||
|
import { ProjectsService } from "./services/projects.service.js";
|
||||||
|
import { ProjectFunctionsService } from "./services/project-functions.service.js";
|
||||||
|
import { UsersController } from "./controllers/users.controller.js";
|
||||||
|
import { FunctionsModule } from "./functions/functions.module.js";
|
||||||
|
import { HealthService } from "./services/health.service.js";
|
||||||
|
import { HealthController } from "./controllers/health.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
AuthController,
|
||||||
|
UsersController,
|
||||||
|
ProjectsController,
|
||||||
|
FunctionsController,
|
||||||
|
HealthController,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
JwtModule.register({
|
||||||
|
secret: JWT_SECRET,
|
||||||
|
signOptions: { expiresIn: "3600s" },
|
||||||
|
}),
|
||||||
|
MongooseModule.forRoot(
|
||||||
|
process.env.MONGO_URL ?? "mongodb://sigma:supersecret@localhost:27017"
|
||||||
|
),
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{
|
||||||
|
name: User.name,
|
||||||
|
schema: UserSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Project.name,
|
||||||
|
schema: ProjectSchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Function.name,
|
||||||
|
schema: FunctionSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
FunctionsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
UsersService,
|
||||||
|
ProjectsService,
|
||||||
|
ProjectFunctionsService,
|
||||||
|
HealthService,
|
||||||
|
BcryptService,
|
||||||
|
LocalStrategy,
|
||||||
|
JwtStrategy,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
1
aws_sigma_service/api/src/auth/const.ts
Normal file
1
aws_sigma_service/api/src/auth/const.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const JWT_SECRET = process.env["JWT_SECRET"] ?? "supersecret";
|
||||||
5
aws_sigma_service/api/src/auth/jwt-auth.guard.ts
Normal file
5
aws_sigma_service/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||||
19
aws_sigma_service/api/src/auth/jwt.strategy.ts
Normal file
19
aws_sigma_service/api/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
|
import { JWT_SECRET } from "./const.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: JWT_SECRET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
return { userId: payload.sub, username: payload.username };
|
||||||
|
}
|
||||||
|
}
|
||||||
5
aws_sigma_service/api/src/auth/local-auth.guard.ts
Normal file
5
aws_sigma_service/api/src/auth/local-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalAuthGuard extends AuthGuard("local") {}
|
||||||
21
aws_sigma_service/api/src/auth/local.strategy.ts
Normal file
21
aws_sigma_service/api/src/auth/local.strategy.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
|
import { Strategy } from "passport-local";
|
||||||
|
import { AuthService } from "../services/auth.service.js";
|
||||||
|
import type { UserDocument } from "../schemas/user.schema.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private authService: AuthService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(username: string, password: string): Promise<UserDocument> {
|
||||||
|
const user = await this.authService.validateUser(username, password);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
aws_sigma_service/api/src/controllers/auth.controller.ts
Normal file
16
aws_sigma_service/api/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, HttpCode, Post, Request, UseGuards } from "@nestjs/common";
|
||||||
|
import type { Request as ExpressRequest } from "express";
|
||||||
|
import { LocalAuthGuard } from "../auth/local-auth.guard.js";
|
||||||
|
import { AuthService } from "../services/auth.service.js";
|
||||||
|
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@UseGuards(LocalAuthGuard)
|
||||||
|
@HttpCode(200)
|
||||||
|
@Post("login")
|
||||||
|
async login(@Request() req: ExpressRequest): Promise<{ auth_token: string }> {
|
||||||
|
return this.authService.login(req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { CreateFunctionDto, UpdateFunctionDto } from "@sigma/common";
|
||||||
|
import type { FunctionDocument } from "../schemas/function.schema.js";
|
||||||
|
import { ProjectFunctionsService } from "../services/project-functions.service.js";
|
||||||
|
|
||||||
|
@Controller("projects/:projectId/functions")
|
||||||
|
export class FunctionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly projectFunctionsService: ProjectFunctionsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("projectId") projectId: string,
|
||||||
|
@Body() payload: CreateFunctionDto
|
||||||
|
): Promise<FunctionDocument> {
|
||||||
|
return this.projectFunctionsService.create(
|
||||||
|
request.user.userId,
|
||||||
|
projectId,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Put(":functionId")
|
||||||
|
async update(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("projectId") projectId: string,
|
||||||
|
@Param("functionId") functionId: string,
|
||||||
|
@Body() payload: UpdateFunctionDto
|
||||||
|
): Promise<FunctionDocument> {
|
||||||
|
const func = await this.projectFunctionsService.update(
|
||||||
|
request.user.userId,
|
||||||
|
projectId,
|
||||||
|
functionId,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!func) {
|
||||||
|
throw new NotFoundException("Function not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return func;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete(":functionId")
|
||||||
|
@HttpCode(204)
|
||||||
|
async delete(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("projectId") projectId: string,
|
||||||
|
@Param("functionId") functionId: string
|
||||||
|
): Promise<FunctionDocument | null> {
|
||||||
|
const func = await this.projectFunctionsService.delete(
|
||||||
|
request.user.userId,
|
||||||
|
projectId,
|
||||||
|
functionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!func) {
|
||||||
|
throw new NotFoundException("Function not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return func;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
aws_sigma_service/api/src/controllers/health.controller.ts
Normal file
13
aws_sigma_service/api/src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from "@nestjs/common";
|
||||||
|
import { HealthService } from "../services/health.service.js";
|
||||||
|
import { CpuInfoDto } from "@sigma/common";
|
||||||
|
|
||||||
|
@Controller("health")
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get("/cpu")
|
||||||
|
getCpuInfo(): CpuInfoDto {
|
||||||
|
return { usage: this.healthService.getServerCpuUsage() };
|
||||||
|
}
|
||||||
|
}
|
||||||
79
aws_sigma_service/api/src/controllers/projects.controller.ts
Normal file
79
aws_sigma_service/api/src/controllers/projects.controller.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { ProjectsService } from "../services/projects.service.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import {
|
||||||
|
CreateProjectDto,
|
||||||
|
ProjectDto,
|
||||||
|
QueryProjectDto,
|
||||||
|
UpdateProjectDto,
|
||||||
|
} from "@sigma/common";
|
||||||
|
import type { ProjectDocument } from "../schemas/project.schema.js";
|
||||||
|
|
||||||
|
@Controller("projects")
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(private readonly projectsService: ProjectsService) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Body() createProjectDto: CreateProjectDto
|
||||||
|
): Promise<ProjectDocument> {
|
||||||
|
return this.projectsService.create(request.user.userId, createProjectDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get()
|
||||||
|
findAll(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Query() query: QueryProjectDto
|
||||||
|
): Promise<ProjectDocument[]> {
|
||||||
|
return this.projectsService.findAll(request.user.userId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get(":id")
|
||||||
|
findOne(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("id") id: string
|
||||||
|
): Promise<ProjectDto | null> {
|
||||||
|
return this.projectsService.findOne(request.user.userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Patch(":id")
|
||||||
|
update(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() updateProjectDto: UpdateProjectDto
|
||||||
|
): Promise<ProjectDocument | null> {
|
||||||
|
return this.projectsService.update(
|
||||||
|
request.user.userId,
|
||||||
|
id,
|
||||||
|
updateProjectDto
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(204)
|
||||||
|
remove(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("id") id: string
|
||||||
|
): Promise<ProjectDocument | null> {
|
||||||
|
return this.projectsService.remove(request.user.userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
aws_sigma_service/api/src/controllers/users.controller.ts
Normal file
69
aws_sigma_service/api/src/controllers/users.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
Get,
|
||||||
|
ConflictException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request as ExpressRequest } from "express";
|
||||||
|
import { UsersService } from "../services/users.service.js";
|
||||||
|
import { BcryptService } from "../services/bcrypt.service.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { RegisterUserDto, UpdateUserDto, UserDto } from "@sigma/common";
|
||||||
|
|
||||||
|
@Controller("users")
|
||||||
|
export class UsersController {
|
||||||
|
constructor(
|
||||||
|
private usersService: UsersService,
|
||||||
|
private bcryptService: BcryptService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(201)
|
||||||
|
async register(@Body() registerDto: RegisterUserDto): Promise<UserDto> {
|
||||||
|
try {
|
||||||
|
const user = await this.usersService.create({
|
||||||
|
username: registerDto.username,
|
||||||
|
passwordHash: await this.bcryptService.hashPassword(
|
||||||
|
registerDto.password
|
||||||
|
),
|
||||||
|
projects: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: user.username,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if ("code" in error && error.code === 11000) {
|
||||||
|
throw new ConflictException("Username already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Put()
|
||||||
|
async update(
|
||||||
|
@Request() req: ExpressRequest,
|
||||||
|
@Body() updateDto: UpdateUserDto
|
||||||
|
) {
|
||||||
|
this.usersService.update(req.user?._id, {
|
||||||
|
passwordHash: await this.bcryptService.hashPassword(updateDto.password),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get("me")
|
||||||
|
async getProfile(@Request() req: ExpressRequest): Promise<UserDto> {
|
||||||
|
const user = await this.usersService.findOne(req.user?.username);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: user!.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
aws_sigma_service/api/src/functions/execution.controller.ts
Normal file
65
aws_sigma_service/api/src/functions/execution.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { All, Controller, Param, Req, Res } from "@nestjs/common";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { FunctionExecutionService } from "./execution.service.js";
|
||||||
|
|
||||||
|
@Controller("exec")
|
||||||
|
export class ExecutionController {
|
||||||
|
constructor(
|
||||||
|
private readonly functionExecutionService: FunctionExecutionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@All(":projectSlug")
|
||||||
|
async handleRoot(
|
||||||
|
@Param("projectSlug") projectSlug: string,
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
await this.dispatchExecution(projectSlug, request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@All(":projectSlug/*path")
|
||||||
|
async handleNested(
|
||||||
|
@Param("projectSlug") projectSlug: string,
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
await this.dispatchExecution(projectSlug, request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatchExecution(
|
||||||
|
projectSlug: string,
|
||||||
|
request: Request,
|
||||||
|
response: Response
|
||||||
|
) {
|
||||||
|
const executionResult = await this.functionExecutionService.execute(
|
||||||
|
projectSlug,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
if (executionResult.responsePayload) {
|
||||||
|
const { statusCode, headers, body } = executionResult.responsePayload;
|
||||||
|
this.applyHeaders(response, headers);
|
||||||
|
this.sendBody(response, statusCode ?? 200, body ?? "");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.sendBody(response, 500, executionResult.stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyHeaders(
|
||||||
|
response: Response,
|
||||||
|
headers: Record<string, string> | undefined
|
||||||
|
) {
|
||||||
|
if (!headers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
response.setHeader(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendBody(response: Response, statusCode: number, body: string) {
|
||||||
|
response.status(statusCode).send(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
aws_sigma_service/api/src/functions/execution.queue.ts
Normal file
64
aws_sigma_service/api/src/functions/execution.queue.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export class ExecutionQueueOverflowError extends Error {
|
||||||
|
constructor(message = "Execution queue is full") {
|
||||||
|
super(message);
|
||||||
|
this.name = "ExecutionQueueOverflowError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task<T> = () => Promise<T>;
|
||||||
|
|
||||||
|
interface PendingTask {
|
||||||
|
task: () => Promise<unknown>;
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExecutionQueue {
|
||||||
|
private running = 0;
|
||||||
|
private readonly queue: PendingTask[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly concurrency: number,
|
||||||
|
private readonly queueLimit: number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
run<T>(task: Task<T>): Promise<T> {
|
||||||
|
if (this.running < this.concurrency) {
|
||||||
|
return this.execute(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = this.queueLimit;
|
||||||
|
if (Number.isFinite(limit) && limit >= 0 && this.queue.length >= limit) {
|
||||||
|
throw new ExecutionQueueOverflowError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
this.queue.push({
|
||||||
|
task: () => task(),
|
||||||
|
resolve: (value) => resolve(value as T),
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute<T>(task: Task<T>): Promise<T> {
|
||||||
|
this.running += 1;
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
this.running -= 1;
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processQueue(): void {
|
||||||
|
while (this.running < this.concurrency && this.queue.length > 0) {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.execute(next.task).then(next.resolve).catch(next.reject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
310
aws_sigma_service/api/src/functions/execution.service.ts
Normal file
310
aws_sigma_service/api/src/functions/execution.service.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
RequestTimeoutException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { FunctionsService } from "./functions.service.js";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import {
|
||||||
|
ExecutionQueue,
|
||||||
|
ExecutionQueueOverflowError,
|
||||||
|
} from "./execution.queue.js";
|
||||||
|
import {
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
validate,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export interface ExecutionResponse {
|
||||||
|
project: string;
|
||||||
|
function: string;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
durationMs: number;
|
||||||
|
timedOut: boolean;
|
||||||
|
responsePayload?: ExecutorHttpResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExecutorHttpResponse {
|
||||||
|
@IsNumber({})
|
||||||
|
@Min(100)
|
||||||
|
@Max(599)
|
||||||
|
statusCode!: number;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
headers!: Record<string, string>;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
body!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FunctionExecutionService {
|
||||||
|
private baseRuntimeDir = "/tmp/sigma";
|
||||||
|
private executorBinary =
|
||||||
|
process.env.EXECUTOR_BIN_PATH ??
|
||||||
|
path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"..",
|
||||||
|
"executor",
|
||||||
|
"target",
|
||||||
|
"release",
|
||||||
|
"executor"
|
||||||
|
);
|
||||||
|
|
||||||
|
private timeoutMs = 1000;
|
||||||
|
private maxConcurrency = 10;
|
||||||
|
private maxQueueSize = 32;
|
||||||
|
|
||||||
|
private queue = new ExecutionQueue(this.maxConcurrency, this.maxQueueSize);
|
||||||
|
|
||||||
|
constructor(private functionsService: FunctionsService) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
projectSlug: string,
|
||||||
|
request: Request
|
||||||
|
): Promise<ExecutionResponse> {
|
||||||
|
const project = await this.functionsService.findProjectBySlug(projectSlug);
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundException(`Project ${projectSlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionPath = this.extractFunctionPath(
|
||||||
|
request.path ?? request.url,
|
||||||
|
projectSlug
|
||||||
|
);
|
||||||
|
|
||||||
|
const func = await this.functionsService.findRunnableFunction(
|
||||||
|
project._id,
|
||||||
|
functionPath,
|
||||||
|
request.method
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!func) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`No function for ${request.method.toUpperCase()} ${functionPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.functionsService.ensureCpuQuota(project);
|
||||||
|
|
||||||
|
const eventPayload = this.buildEventPayload(request, functionPath);
|
||||||
|
const executionResult = await this.runExecutor(
|
||||||
|
projectSlug,
|
||||||
|
func.code,
|
||||||
|
eventPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.functionsService.recordCpuUsage(
|
||||||
|
project,
|
||||||
|
executionResult.durationMs
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.functionsService.recordInvocation(
|
||||||
|
func._id.toString(),
|
||||||
|
executionResult.exitCode === 0,
|
||||||
|
executionResult.stderr
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (executionResult.timedOut) {
|
||||||
|
throw new RequestTimeoutException("Function execution timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: project.slug,
|
||||||
|
function: func.name,
|
||||||
|
...executionResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFunctionPath(
|
||||||
|
pathname: string | undefined,
|
||||||
|
slug: string
|
||||||
|
): string {
|
||||||
|
if (!pathname) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = `/exec/${slug}`;
|
||||||
|
const index = pathname.indexOf(needle);
|
||||||
|
let relative =
|
||||||
|
index === -1 ? pathname : pathname.slice(index + needle.length);
|
||||||
|
if (!relative || relative.length === 0) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relative.startsWith("/")) {
|
||||||
|
relative = `/${relative}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relative.length > 1 && relative.endsWith("/")) {
|
||||||
|
relative = relative.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relative || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEventPayload(request: Request, functionPath: string) {
|
||||||
|
const body = this.serializeBody(request.body);
|
||||||
|
const pathValue = request.path ?? request.url ?? functionPath;
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
path: pathValue,
|
||||||
|
originalUrl: request.originalUrl,
|
||||||
|
functionPath,
|
||||||
|
headers: request.headers,
|
||||||
|
query: request.query,
|
||||||
|
params: request.params,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeBody(body: unknown): unknown {
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
return body.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runExecutor(slug: string, code: string, eventPayload: unknown) {
|
||||||
|
try {
|
||||||
|
return await this.queue.run(() =>
|
||||||
|
this.executeWithinRuntime(slug, code, eventPayload)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ExecutionQueueOverflowError) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"Execution queue is full, please retry later"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeWithinRuntime(
|
||||||
|
slug: string,
|
||||||
|
code: string,
|
||||||
|
eventPayload: unknown
|
||||||
|
) {
|
||||||
|
await fs.mkdir(this.baseRuntimeDir, { recursive: true });
|
||||||
|
const tempDir = path.join(this.baseRuntimeDir, slug, randomUUID());
|
||||||
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const scriptPath = path.join(tempDir, "function.js");
|
||||||
|
const eventPath = path.join(tempDir, "event.json");
|
||||||
|
|
||||||
|
await fs.writeFile(scriptPath, `${code}\n`, "utf8");
|
||||||
|
await fs.writeFile(
|
||||||
|
eventPath,
|
||||||
|
JSON.stringify(eventPayload, (_, value) => value ?? null, 2),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
const result = await this.spawnExecutor(slug, scriptPath, tempDir);
|
||||||
|
const durationMs = performance.now() - start;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
durationMs,
|
||||||
|
responsePayload: await this.extractResponsePayload(result.stdout),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: "",
|
||||||
|
stderr: (e as Error).message,
|
||||||
|
exitCode: null,
|
||||||
|
durationMs: performance.now() - start,
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnExecutor(slug: string, scriptPath: string, cwd: string) {
|
||||||
|
return new Promise<{
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
timedOut: boolean;
|
||||||
|
}>((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
this.executorBinary,
|
||||||
|
["--enable-fs", "--enable-sql", `scope_${slug}`, scriptPath],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}, this.timeoutMs);
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new InternalServerErrorException(err.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exitCode: code,
|
||||||
|
timedOut,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractResponsePayload(
|
||||||
|
stdout: string
|
||||||
|
): Promise<ExecutorHttpResponse | null> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stdout.trim());
|
||||||
|
|
||||||
|
const response = new ExecutorHttpResponse();
|
||||||
|
response.statusCode = parsed.statusCode;
|
||||||
|
response.headers = parsed.headers;
|
||||||
|
response.body = parsed.body;
|
||||||
|
|
||||||
|
const errors = await validate(response);
|
||||||
|
|
||||||
|
if (errors.length === 0) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
aws_sigma_service/api/src/functions/functions.module.ts
Normal file
19
aws_sigma_service/api/src/functions/functions.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { ExecutionController } from "./execution.controller.js";
|
||||||
|
import { FunctionExecutionService } from "./execution.service.js";
|
||||||
|
import { FunctionsService } from "./functions.service.js";
|
||||||
|
import { Project, ProjectSchema } from "../schemas/project.schema.js";
|
||||||
|
import { Function, FunctionSchema } from "../schemas/function.schema.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Project.name, schema: ProjectSchema },
|
||||||
|
{ name: Function.name, schema: FunctionSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [ExecutionController],
|
||||||
|
providers: [FunctionExecutionService, FunctionsService],
|
||||||
|
})
|
||||||
|
export class FunctionsModule {}
|
||||||
162
aws_sigma_service/api/src/functions/functions.service.ts
Normal file
162
aws_sigma_service/api/src/functions/functions.service.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { HttpException, HttpStatus, Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import type { Model } from "mongoose";
|
||||||
|
import { Project, type ProjectDocument } from "../schemas/project.schema.js";
|
||||||
|
import {
|
||||||
|
Function as SigmaFunction,
|
||||||
|
type FunctionDocument,
|
||||||
|
} from "../schemas/function.schema.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FunctionsService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Project.name)
|
||||||
|
private readonly projectModel: Model<Project>,
|
||||||
|
@InjectModel(SigmaFunction.name)
|
||||||
|
private readonly functionModel: Model<SigmaFunction>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findProjectBySlug(slug: string): Promise<ProjectDocument | null> {
|
||||||
|
return this.projectModel.findOne({ slug }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRunnableFunction(
|
||||||
|
projectId: ProjectDocument["_id"],
|
||||||
|
requestPath: string,
|
||||||
|
method: string
|
||||||
|
): Promise<FunctionDocument | null> {
|
||||||
|
const functions = await this.functionModel
|
||||||
|
.find({ project: projectId })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const normalizedPath = this.normalizePath(requestPath);
|
||||||
|
const upperMethod = method.toUpperCase();
|
||||||
|
|
||||||
|
for (const func of functions) {
|
||||||
|
if (!this.supportsMethod(func, upperMethod)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pathMatches(func.path, normalizedPath)) {
|
||||||
|
return func;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureCpuQuota(project: ProjectDocument): Promise<void> {
|
||||||
|
const quota = project.cpuTimeQuotaMsPerMinute ?? 1000;
|
||||||
|
const updated = this.resetUsageWindowIfNeeded(project);
|
||||||
|
if (project.cpuTimeUsedMs >= quota) {
|
||||||
|
throw new HttpException(
|
||||||
|
`CPU time quota exceeded for project ${project.slug}`,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await project.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordCpuUsage(
|
||||||
|
project: ProjectDocument,
|
||||||
|
elapsedMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const quota = project.cpuTimeQuotaMsPerMinute ?? 1000;
|
||||||
|
this.resetUsageWindowIfNeeded(project);
|
||||||
|
project.cpuTimeUsedMs = Math.min(
|
||||||
|
quota,
|
||||||
|
(project.cpuTimeUsedMs ?? 0) + Math.max(0, Math.round(elapsedMs))
|
||||||
|
);
|
||||||
|
|
||||||
|
await project.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordInvocation(
|
||||||
|
functionId: string,
|
||||||
|
successful: boolean,
|
||||||
|
logs: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const func = await this.functionModel.findByIdAndUpdate(functionId, {
|
||||||
|
$inc: { invocationCount: 1, errorCount: successful ? 0 : 1 },
|
||||||
|
lastInvocation: new Date(),
|
||||||
|
$push: { logs: { $each: logs, $slice: -100 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (func) {
|
||||||
|
await this.projectModel.findByIdAndUpdate(func._id, {
|
||||||
|
$inc: {
|
||||||
|
functionsInvocationCount: 1,
|
||||||
|
functionsErrorCount: successful ? 0 : 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private supportsMethod(func: FunctionDocument, method: string): boolean {
|
||||||
|
if (!func.methods || func.methods.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return func.methods.some((allowed) => allowed.toUpperCase() === method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pathMatches(pattern: string, requestPath: string): boolean {
|
||||||
|
const normalizedPattern = this.normalizePath(pattern);
|
||||||
|
if (normalizedPattern === "/*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patternSegments = normalizedPattern.split("/").filter(Boolean);
|
||||||
|
const requestSegments = requestPath.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (patternSegments.length !== requestSegments.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patternSegments.every((segment, index) => {
|
||||||
|
const value = requestSegments[index];
|
||||||
|
if (segment.startsWith(":")) {
|
||||||
|
return typeof value === "string" && value.length > 0;
|
||||||
|
}
|
||||||
|
return segment === value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePath(input: string): string {
|
||||||
|
if (!input || input === "") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = input.startsWith("/") ? input : `/${input}`;
|
||||||
|
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetUsageWindowIfNeeded(project: ProjectDocument): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const startedAt = project.cpuTimeWindowStartedAt?.getTime() ?? 0;
|
||||||
|
if (now - startedAt >= 60_000) {
|
||||||
|
project.cpuTimeWindowStartedAt = new Date(now);
|
||||||
|
project.cpuTimeUsedMs = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.cpuTimeWindowStartedAt) {
|
||||||
|
project.cpuTimeWindowStartedAt = new Date(now);
|
||||||
|
project.cpuTimeUsedMs = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.cpuTimeUsedMs == null) {
|
||||||
|
project.cpuTimeUsedMs = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
aws_sigma_service/api/src/main.ts
Normal file
24
aws_sigma_service/api/src/main.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { AppModule } from "./app.module.js";
|
||||||
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
cors: {
|
||||||
|
credentials: true,
|
||||||
|
origin: "http://localhost:1234",
|
||||||
|
},
|
||||||
|
// logger: ["debug", "log", "error", "warn", "fatal"],
|
||||||
|
});
|
||||||
|
app.setGlobalPrefix("/api/v1");
|
||||||
|
app.set("query parser", "extended");
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT || 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
36
aws_sigma_service/api/src/schemas/function.schema.ts
Normal file
36
aws_sigma_service/api/src/schemas/function.schema.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import type { HydratedDocument } from "mongoose";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
@Schema()
|
||||||
|
export class Function {
|
||||||
|
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: "Project" })
|
||||||
|
project!: mongoose.Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
path!: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
methods!: string[];
|
||||||
|
|
||||||
|
@Prop({ default: "// Write your function code here" })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
invocationCount: number = 0;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
errorCount: number = 0;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
logs: string[] = [];
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
lastInvocation?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FunctionDocument = HydratedDocument<Function>;
|
||||||
|
export const FunctionSchema = SchemaFactory.createForClass(Function);
|
||||||
43
aws_sigma_service/api/src/schemas/project.schema.ts
Normal file
43
aws_sigma_service/api/src/schemas/project.schema.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import type { Function } from "./function.schema.js";
|
||||||
|
import type { HydratedDocument } from "mongoose";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
@Schema()
|
||||||
|
export class Project {
|
||||||
|
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: "User" })
|
||||||
|
owner!: mongoose.Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Prop({ unique: true })
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Function" }],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
functions!: Function[];
|
||||||
|
|
||||||
|
@Prop({ default: 1000 })
|
||||||
|
cpuTimeQuotaMsPerMinute!: number;
|
||||||
|
|
||||||
|
@Prop({ default: Date.now })
|
||||||
|
cpuTimeWindowStartedAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
cpuTimeUsedMs!: number;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
functionsInvocationCount: number = 0;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
functionsErrorCount: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectDocument = HydratedDocument<Project>;
|
||||||
|
export const ProjectSchema = SchemaFactory.createForClass(Project);
|
||||||
22
aws_sigma_service/api/src/schemas/user.schema.ts
Normal file
22
aws_sigma_service/api/src/schemas/user.schema.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import type { HydratedDocument } from "mongoose";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import type { Project } from "./project.schema.js";
|
||||||
|
|
||||||
|
@Schema()
|
||||||
|
export class User {
|
||||||
|
@Prop({ unique: true })
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
passwordHash!: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Project" }],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
projects!: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserDocument = HydratedDocument<User>;
|
||||||
|
export const UserSchema = SchemaFactory.createForClass(User);
|
||||||
41
aws_sigma_service/api/src/services/auth.service.ts
Normal file
41
aws_sigma_service/api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { UsersService } from "./users.service.js";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import type { UserDocument } from "../schemas/user.schema.js";
|
||||||
|
import { BcryptService } from "./bcrypt.service.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private usersService: UsersService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private bcryptService: BcryptService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<UserDocument | null> {
|
||||||
|
const user = await this.usersService.findOne(username);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user &&
|
||||||
|
(await this.bcryptService.comparePasswords(password, user.passwordHash))
|
||||||
|
) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(user: UserDocument): Promise<{ auth_token: string }> {
|
||||||
|
const payload = {
|
||||||
|
sub: user._id,
|
||||||
|
username: user.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth_token: await this.jwtService.signAsync(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
aws_sigma_service/api/src/services/bcrypt.service.ts
Normal file
15
aws_sigma_service/api/src/services/bcrypt.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as bcrypt from "bcrypt";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BcryptService {
|
||||||
|
async hashPassword(password: string): Promise<string> {
|
||||||
|
const saltRounds = 10;
|
||||||
|
|
||||||
|
return bcrypt.hash(password, saltRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async comparePasswords(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
aws_sigma_service/api/src/services/health.service.ts
Normal file
42
aws_sigma_service/api/src/services/health.service.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as os from "os";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
private totalTime?: number;
|
||||||
|
private usedTime?: number;
|
||||||
|
private currentUsage?: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
setInterval(() => this.updateCpuUsage(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCpuUsage() {
|
||||||
|
const cpus = os.cpus();
|
||||||
|
|
||||||
|
const totalTime = cpus.reduce(
|
||||||
|
(acc, cpu) =>
|
||||||
|
acc + Object.values(cpu.times).reduce((acc, time) => acc + time, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedTime = cpus.reduce(
|
||||||
|
(acc, cpu) => acc + cpu.times.user + cpu.times.sys,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.totalTime && this.usedTime) {
|
||||||
|
const totalDiff = totalTime - this.totalTime;
|
||||||
|
const usedDiff = usedTime - this.usedTime;
|
||||||
|
|
||||||
|
this.currentUsage = (usedDiff / totalDiff) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.totalTime = totalTime;
|
||||||
|
this.usedTime = usedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerCpuUsage(): number {
|
||||||
|
return this.currentUsage ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
aws_sigma_service/api/src/services/project-functions.service.ts
Normal file
111
aws_sigma_service/api/src/services/project-functions.service.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import type { Model, ObjectId } from "mongoose";
|
||||||
|
import { Project, type ProjectDocument } from "../schemas/project.schema.js";
|
||||||
|
import {
|
||||||
|
Function as SigmaFunction,
|
||||||
|
type FunctionDocument,
|
||||||
|
} from "../schemas/function.schema.js";
|
||||||
|
import { CreateFunctionDto, UpdateFunctionDto } from "@sigma/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectFunctionsService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Project.name)
|
||||||
|
private readonly projectModel: Model<Project>,
|
||||||
|
@InjectModel(SigmaFunction.name)
|
||||||
|
private readonly functionModel: Model<SigmaFunction>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
owner: ObjectId,
|
||||||
|
projectId: string,
|
||||||
|
payload: CreateFunctionDto
|
||||||
|
): Promise<FunctionDocument> {
|
||||||
|
const project = await this.projectModel
|
||||||
|
.findOne({
|
||||||
|
_id: projectId,
|
||||||
|
owner,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundException("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdFunction = await this.functionModel.create({
|
||||||
|
project: project._id,
|
||||||
|
name: payload.name,
|
||||||
|
path: payload.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.appendFunctionReference(project, createdFunction._id);
|
||||||
|
|
||||||
|
return createdFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(
|
||||||
|
owner: ObjectId,
|
||||||
|
projectId: string,
|
||||||
|
functionId: string
|
||||||
|
): Promise<FunctionDocument | null> {
|
||||||
|
const project = await this.projectModel.findOne({
|
||||||
|
_id: projectId,
|
||||||
|
owner,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundException("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const func = await this.functionModel.findOneAndDelete({
|
||||||
|
project: project._id,
|
||||||
|
_id: functionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await project.updateOne(
|
||||||
|
{
|
||||||
|
functions: project.functions.filter(
|
||||||
|
(funcId) => funcId.toString() !== functionId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return func;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
owner: ObjectId,
|
||||||
|
projectId: string,
|
||||||
|
functionId: string,
|
||||||
|
payload: UpdateFunctionDto
|
||||||
|
): Promise<FunctionDocument | null> {
|
||||||
|
const project = await this.projectModel.findOne({
|
||||||
|
_id: projectId,
|
||||||
|
owner,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundException("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.functionModel.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
project: project._id,
|
||||||
|
_id: functionId,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendFunctionReference(
|
||||||
|
project: ProjectDocument,
|
||||||
|
functionId: FunctionDocument["_id"]
|
||||||
|
): Promise<void> {
|
||||||
|
await this.projectModel
|
||||||
|
.updateOne({ _id: project._id }, { $addToSet: { functions: functionId } })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
aws_sigma_service/api/src/services/projects.service.ts
Normal file
126
aws_sigma_service/api/src/services/projects.service.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import type { Model, ObjectId } from "mongoose";
|
||||||
|
import { Project, type ProjectDocument } from "../schemas/project.schema.js";
|
||||||
|
import {
|
||||||
|
Function as SigmaFunction,
|
||||||
|
type FunctionDocument,
|
||||||
|
} from "../schemas/function.schema.js";
|
||||||
|
import {
|
||||||
|
CreateProjectDto,
|
||||||
|
FunctionDto,
|
||||||
|
ProjectDto,
|
||||||
|
UpdateProjectDto,
|
||||||
|
} from "@sigma/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectsService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Project.name) private projectModel: Model<Project>,
|
||||||
|
@InjectModel(SigmaFunction.name)
|
||||||
|
private functionModel: Model<SigmaFunction>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
owner: ObjectId,
|
||||||
|
createProjectDto: CreateProjectDto
|
||||||
|
): Promise<ProjectDocument> {
|
||||||
|
const createdProject = new this.projectModel({
|
||||||
|
...createProjectDto,
|
||||||
|
owner,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdProject.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
owner: ObjectId,
|
||||||
|
query: UpdateProjectDto
|
||||||
|
): Promise<ProjectDocument[]> {
|
||||||
|
return this.projectModel
|
||||||
|
.find({
|
||||||
|
owner,
|
||||||
|
...query,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(owner: ObjectId, id: string): Promise<ProjectDto | null> {
|
||||||
|
const project = await this.projectModel
|
||||||
|
.findOne({
|
||||||
|
_id: id,
|
||||||
|
owner,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const functions = await this.functionModel
|
||||||
|
.find({ project: project._id })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
return this.toProjectDto(project, functions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
owner: ObjectId,
|
||||||
|
id: string,
|
||||||
|
updateProjectDto: UpdateProjectDto
|
||||||
|
): Promise<ProjectDocument | null> {
|
||||||
|
return this.projectModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
_id: id,
|
||||||
|
owner,
|
||||||
|
},
|
||||||
|
updateProjectDto,
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(owner: ObjectId, id: string): Promise<ProjectDocument | null> {
|
||||||
|
return this.projectModel
|
||||||
|
.findOneAndDelete({
|
||||||
|
_id: id,
|
||||||
|
owner,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toProjectDto(
|
||||||
|
project: ProjectDocument,
|
||||||
|
functions: FunctionDocument[]
|
||||||
|
): ProjectDto {
|
||||||
|
return {
|
||||||
|
_id: project._id.toString(),
|
||||||
|
name: project.name,
|
||||||
|
slug: project.slug,
|
||||||
|
description: project.description,
|
||||||
|
cpuTimeQuotaMsPerMinute: project.cpuTimeQuotaMsPerMinute ?? 0,
|
||||||
|
cpuTimeWindowStartedAt:
|
||||||
|
project.cpuTimeWindowStartedAt?.toISOString() ??
|
||||||
|
new Date(0).toISOString(),
|
||||||
|
cpuTimeUsedMs: project.cpuTimeUsedMs ?? 0,
|
||||||
|
functionsInvocationCount: project.functionsInvocationCount ?? 0,
|
||||||
|
functionsErrorCount: project.functionsErrorCount ?? 0,
|
||||||
|
functions: functions.map((func) => this.toFunctionDto(func)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toFunctionDto(func: FunctionDocument): FunctionDto {
|
||||||
|
return {
|
||||||
|
_id: func._id.toString(),
|
||||||
|
name: func.name,
|
||||||
|
path: func.path,
|
||||||
|
methods: func.methods ?? [],
|
||||||
|
code: func.code,
|
||||||
|
invocations: func.invocationCount ?? 0,
|
||||||
|
errors: func.errorCount ?? 0,
|
||||||
|
logs: func.logs ?? [],
|
||||||
|
lastInvocation: func.lastInvocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
aws_sigma_service/api/src/services/users.service.ts
Normal file
27
aws_sigma_service/api/src/services/users.service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { User, type UserDocument } from "../schemas/user.schema.js";
|
||||||
|
import type { Model } from "mongoose";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(@InjectModel(User.name) private userModel: Model<User>) {}
|
||||||
|
|
||||||
|
async create(user: User): Promise<UserDocument> {
|
||||||
|
const createdUser = new this.userModel(user);
|
||||||
|
return createdUser.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(username: string): Promise<UserDocument | null> {
|
||||||
|
return this.userModel.findOne({
|
||||||
|
username: username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updateData: Partial<User>
|
||||||
|
): Promise<UserDocument | null> {
|
||||||
|
return this.userModel.findByIdAndUpdate(id, updateData, { new: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
4
aws_sigma_service/api/tsconfig.build.json
Normal file
4
aws_sigma_service/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
10
aws_sigma_service/api/tsconfig.json
Normal file
10
aws_sigma_service/api/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"types": ["node"],
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
}
|
||||||
|
}
|
||||||
23
aws_sigma_service/common/package.json
Normal file
23
aws_sigma_service/common/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@sigma/common",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"format": "prettier -w src"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
"import": "./dist/lib.js",
|
||||||
|
"types": "./dist/lib.d.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
aws_sigma_service/common/src/dto/cpu-info.dto.ts
Normal file
7
aws_sigma_service/common/src/dto/cpu-info.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
export class CpuInfoDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
usage!: number;
|
||||||
|
}
|
||||||
36
aws_sigma_service/common/src/dto/create-function.dto.ts
Normal file
36
aws_sigma_service/common/src/dto/create-function.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUppercase,
|
||||||
|
Matches,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class CreateFunctionDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@Matches(
|
||||||
|
/^\/(?:$|(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+)(?:\/(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+))*)$/,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"path must be a valid endpoint path (e.g. / or /hello/world/:id)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
path!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsUppercase({ each: true })
|
||||||
|
methods?: string[];
|
||||||
|
}
|
||||||
26
aws_sigma_service/common/src/dto/create-project.dto.ts
Normal file
26
aws_sigma_service/common/src/dto/create-project.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class CreateProjectDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
cpuTimeQuotaMsPerMinute?: number;
|
||||||
|
}
|
||||||
11
aws_sigma_service/common/src/dto/dto.ts
Normal file
11
aws_sigma_service/common/src/dto/dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export * from "./cpu-info.dto.js";
|
||||||
|
export * from "./create-function.dto.js";
|
||||||
|
export * from "./create-project.dto.js";
|
||||||
|
export * from "./function.dto.js";
|
||||||
|
export * from "./project.dto.js";
|
||||||
|
export * from "./query-project.dto.js";
|
||||||
|
export * from "./register-user.dto.js";
|
||||||
|
export * from "./update-function.dto.js";
|
||||||
|
export * from "./update-project.dto.js";
|
||||||
|
export * from "./update-user.dto.js";
|
||||||
|
export * from "./user.dto.js";
|
||||||
39
aws_sigma_service/common/src/dto/function.dto.ts
Normal file
39
aws_sigma_service/common/src/dto/function.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class FunctionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
_id!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
path!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
methods!: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
invocations!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
errors!: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
logs!: string[];
|
||||||
|
|
||||||
|
lastInvocation?: Date;
|
||||||
|
}
|
||||||
45
aws_sigma_service/common/src/dto/project.dto.ts
Normal file
45
aws_sigma_service/common/src/dto/project.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { FunctionDto } from "./function.dto.js";
|
||||||
|
|
||||||
|
export class ProjectDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
_id!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
cpuTimeQuotaMsPerMinute!: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
cpuTimeWindowStartedAt!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
cpuTimeUsedMs!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
functionsInvocationCount!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
functionsErrorCount!: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => FunctionDto)
|
||||||
|
functions!: FunctionDto[];
|
||||||
|
}
|
||||||
4
aws_sigma_service/common/src/dto/query-project.dto.ts
Normal file
4
aws_sigma_service/common/src/dto/query-project.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { ProjectDto } from "./project.dto.js";
|
||||||
|
|
||||||
|
export class QueryProjectDto extends PartialType(ProjectDto) {}
|
||||||
11
aws_sigma_service/common/src/dto/register-user.dto.ts
Normal file
11
aws_sigma_service/common/src/dto/register-user.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class RegisterUserDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@MinLength(8)
|
||||||
|
@IsString()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
4
aws_sigma_service/common/src/dto/update-function.dto.ts
Normal file
4
aws_sigma_service/common/src/dto/update-function.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateFunctionDto } from "./create-function.dto.js";
|
||||||
|
|
||||||
|
export class UpdateFunctionDto extends PartialType(CreateFunctionDto) {}
|
||||||
4
aws_sigma_service/common/src/dto/update-project.dto.ts
Normal file
4
aws_sigma_service/common/src/dto/update-project.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateProjectDto } from "./create-project.dto.js";
|
||||||
|
|
||||||
|
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}
|
||||||
4
aws_sigma_service/common/src/dto/update-user.dto.ts
Normal file
4
aws_sigma_service/common/src/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PickType } from "@nestjs/mapped-types";
|
||||||
|
import { RegisterUserDto } from "./register-user.dto.js";
|
||||||
|
|
||||||
|
export class UpdateUserDto extends PickType(RegisterUserDto, ["password"]) {}
|
||||||
7
aws_sigma_service/common/src/dto/user.dto.ts
Normal file
7
aws_sigma_service/common/src/dto/user.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class UserDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
username!: string;
|
||||||
|
}
|
||||||
1
aws_sigma_service/common/src/lib.ts
Normal file
1
aws_sigma_service/common/src/lib.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dto/dto.js";
|
||||||
9
aws_sigma_service/common/tsconfig.json
Normal file
9
aws_sigma_service/common/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
}
|
||||||
|
}
|
||||||
26
aws_sigma_service/docker-compose.dev.yml
Normal file
26
aws_sigma_service/docker-compose.dev.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:8.2.2-noble
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: sigma
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: supersecret
|
||||||
|
MONGO_INITDB_DATABASE: sigma
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.1-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: sigma
|
||||||
|
POSTGRES_PASSWORD: supersecret
|
||||||
|
POSTGRES_DB: sigma
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
mongo-data:
|
||||||
48
aws_sigma_service/docker-compose.yml
Normal file
48
aws_sigma_service/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
hostname: api
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
MONGO_URL: mongodb://sigma:supersecret@mongo:27017/sigma?authSource=admin
|
||||||
|
POSTGRES_URL: postgres://sigma:supersecret@postgres:5432/sigma
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.nginx
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
ports:
|
||||||
|
- 3000:80
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
hostname: mongo
|
||||||
|
image: mongo:8.2.2-noble
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: sigma
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: supersecret
|
||||||
|
MONGO_INITDB_DATABASE: sigma
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
hostname: postgres
|
||||||
|
image: postgres:18.1-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: sigma
|
||||||
|
POSTGRES_PASSWORD: supersecret
|
||||||
|
POSTGRES_DB: sigma
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
mongo-data:
|
||||||
3
aws_sigma_service/executor/.vscode/settings.json
vendored
Normal file
3
aws_sigma_service/executor/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 4
|
||||||
|
}
|
||||||
3814
aws_sigma_service/executor/Cargo.lock
generated
Normal file
3814
aws_sigma_service/executor/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
aws_sigma_service/executor/Cargo.toml
Normal file
23
aws_sigma_service/executor/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "executor"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.100"
|
||||||
|
axum = "0.8.7"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
boa_engine = "0.21.0"
|
||||||
|
boa_gc = "0.21.0"
|
||||||
|
boa_runtime = { version = "0.21.0", features = ["reqwest-blocking"] }
|
||||||
|
chrono = { version = "0.4.38", default-features = true }
|
||||||
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
|
envy = "0.4.2"
|
||||||
|
futures-concurrency = "7.6.3"
|
||||||
|
futures-lite = "2.6.1"
|
||||||
|
memory-stats = "1.2.0"
|
||||||
|
rlimit = "0.10.2"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.132"
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "json", "chrono"] }
|
||||||
|
tokio = { version = "1.40.0", features = ["rt-multi-thread"] }
|
||||||
58
aws_sigma_service/executor/src/builtins/builtins.d.ts
vendored
Normal file
58
aws_sigma_service/executor/src/builtins/builtins.d.ts
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
declare namespace fs {
|
||||||
|
function readFileSync(
|
||||||
|
path: string,
|
||||||
|
options?: { encoding?: null } | "utf-8"
|
||||||
|
): string;
|
||||||
|
|
||||||
|
function writeFileSync(
|
||||||
|
path: string,
|
||||||
|
data: string,
|
||||||
|
options?: { encoding?: null } | "utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
function existsSync(path: string): boolean;
|
||||||
|
|
||||||
|
function mkdirSync(path: string, options?: { recursive?: boolean });
|
||||||
|
|
||||||
|
function readDirSync(path: string): string[];
|
||||||
|
|
||||||
|
function statSync(path: string): {
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace req {
|
||||||
|
const method: string;
|
||||||
|
const path: string;
|
||||||
|
const originalUrl: string;
|
||||||
|
const functionPath: string;
|
||||||
|
const headers: Record<string, string | string[]>;
|
||||||
|
const query: Record<string, string | string[]>;
|
||||||
|
const params: Record<string, string | string[]>;
|
||||||
|
const body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace res {
|
||||||
|
let statusCode: number;
|
||||||
|
let headers: Record<string, string | string[]>;
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
function setStatus(code: number): typeof res;
|
||||||
|
function setHeader(name: string, value: string | string[]): typeof res;
|
||||||
|
function setHeaders(map: Record<string, string | string[]>): typeof res;
|
||||||
|
function removeHeader(name: string): typeof res;
|
||||||
|
function reset(): typeof res;
|
||||||
|
function send(payload: unknown): typeof res;
|
||||||
|
function json(payload: unknown): typeof res;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace sql {
|
||||||
|
function query<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;
|
||||||
|
|
||||||
|
function execute(
|
||||||
|
query: string,
|
||||||
|
params?: unknown[]
|
||||||
|
): Promise<{ rowsAffected: number }>;
|
||||||
|
}
|
||||||
252
aws_sigma_service/executor/src/builtins/fs.rs
Normal file
252
aws_sigma_service/executor/src/builtins/fs.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use boa_engine::error::JsNativeError;
|
||||||
|
use boa_engine::js_string;
|
||||||
|
use boa_engine::native_function::NativeFunction;
|
||||||
|
use boa_engine::object::{JsObject, ObjectInitializer};
|
||||||
|
use boa_engine::property::Attribute;
|
||||||
|
use boa_engine::{Context, JsError, JsResult, JsString, JsValue};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum Encoding {
|
||||||
|
Utf8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Fs;
|
||||||
|
|
||||||
|
impl Fs {
|
||||||
|
pub const NAME: JsString = js_string!("fs");
|
||||||
|
|
||||||
|
pub fn init(context: &mut Context) -> JsObject {
|
||||||
|
ObjectInitializer::new(context)
|
||||||
|
.property(
|
||||||
|
js_string!("name"),
|
||||||
|
JsString::from(Self::NAME),
|
||||||
|
Attribute::READONLY,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_fn_ptr(Self::read_file_sync),
|
||||||
|
js_string!("readFileSync"),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_fn_ptr(Self::write_file_sync),
|
||||||
|
js_string!("writeFileSync"),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_fn_ptr(Self::exists_sync),
|
||||||
|
js_string!("existsSync"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_fn_ptr(Self::mkdir_sync),
|
||||||
|
js_string!("mkdirSync"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_fn_ptr(Self::readdir_sync),
|
||||||
|
js_string!("readdirSync"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_fn_ptr(Self::stat_sync),
|
||||||
|
js_string!("statSync"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let path = Self::path_from_args(args, 0, "path", "readFileSync", context)?;
|
||||||
|
let encoding = Self::encoding_from_arg(args.get(1), context)?;
|
||||||
|
|
||||||
|
let content = fs::read(&path).map_err(JsError::from_rust)?;
|
||||||
|
|
||||||
|
match encoding {
|
||||||
|
Encoding::Utf8 => {
|
||||||
|
let text = String::from_utf8(content).map_err(JsError::from_rust)?;
|
||||||
|
Ok(JsValue::new(JsString::from(text)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let path = Self::path_from_args(args, 0, "path", "writeFileSync", context)?;
|
||||||
|
let data = Self::string_from_arg(args, 1, "data", "writeFileSync", context)?;
|
||||||
|
let encoding = Self::encoding_from_arg(args.get(2), context)?;
|
||||||
|
|
||||||
|
match encoding {
|
||||||
|
Encoding::Utf8 => {
|
||||||
|
fs::write(path, data).map_err(JsError::from_rust)?;
|
||||||
|
Ok(JsValue::undefined())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let path = Self::path_from_args(args, 0, "path", "existsSync", context)?;
|
||||||
|
Ok(JsValue::new(path.exists()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkdir_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let path = Self::path_from_args(args, 0, "path", "mkdirSync", context)?;
|
||||||
|
let recursive = Self::recursive_from_arg(args.get(1), context)?;
|
||||||
|
|
||||||
|
if recursive {
|
||||||
|
fs::create_dir_all(path).map_err(JsError::from_rust)?;
|
||||||
|
} else {
|
||||||
|
fs::create_dir(path).map_err(JsError::from_rust)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(JsValue::undefined())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readdir_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let path = Self::path_from_args(args, 0, "path", "readdirSync", context)?;
|
||||||
|
let entries = fs::read_dir(path).map_err(JsError::from_rust)?;
|
||||||
|
|
||||||
|
let names = entries
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.map(|dir_entry| {
|
||||||
|
let name = dir_entry.file_name();
|
||||||
|
JsValue::new(JsString::from(name.to_string_lossy().into_owned()))
|
||||||
|
})
|
||||||
|
.map_err(JsError::from_rust)
|
||||||
|
})
|
||||||
|
.collect::<JsResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Self::array_from_values(names, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stat_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let path = Self::path_from_args(args, 0, "path", "statSync", context)?;
|
||||||
|
let metadata = fs::metadata(path).map_err(JsError::from_rust)?;
|
||||||
|
|
||||||
|
let mut stats = ObjectInitializer::new(context);
|
||||||
|
stats
|
||||||
|
.property(
|
||||||
|
js_string!("size"),
|
||||||
|
metadata.len() as f64,
|
||||||
|
Attribute::READONLY | Attribute::ENUMERABLE,
|
||||||
|
)
|
||||||
|
.property(
|
||||||
|
js_string!("isFile"),
|
||||||
|
metadata.is_file(),
|
||||||
|
Attribute::READONLY | Attribute::ENUMERABLE,
|
||||||
|
)
|
||||||
|
.property(
|
||||||
|
js_string!("isDirectory"),
|
||||||
|
metadata.is_dir(),
|
||||||
|
Attribute::READONLY | Attribute::ENUMERABLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(stats.build().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_from_args(
|
||||||
|
args: &[JsValue],
|
||||||
|
index: usize,
|
||||||
|
name: &str,
|
||||||
|
method: &str,
|
||||||
|
context: &mut Context,
|
||||||
|
) -> JsResult<PathBuf> {
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| Self::missing_argument(name, method))?;
|
||||||
|
if value.is_undefined() || value.is_null() {
|
||||||
|
return Err(Self::missing_argument(name, method));
|
||||||
|
}
|
||||||
|
|
||||||
|
let string = value.to_string(context)?.to_std_string_escaped();
|
||||||
|
Ok(PathBuf::from(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_from_arg(
|
||||||
|
args: &[JsValue],
|
||||||
|
index: usize,
|
||||||
|
name: &str,
|
||||||
|
method: &str,
|
||||||
|
context: &mut Context,
|
||||||
|
) -> JsResult<String> {
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| Self::missing_argument(name, method))?;
|
||||||
|
if value.is_undefined() || value.is_null() {
|
||||||
|
return Err(Self::missing_argument(name, method));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(value.to_string(context)?.to_std_string_escaped())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoding_from_arg(arg: Option<&JsValue>, context: &mut Context) -> JsResult<Encoding> {
|
||||||
|
match arg {
|
||||||
|
None => Ok(Encoding::Utf8),
|
||||||
|
Some(value) if value.is_undefined() => Ok(Encoding::Utf8),
|
||||||
|
Some(value) => {
|
||||||
|
if let Some(object) = value.as_object() {
|
||||||
|
let enc_value = object.get(JsString::from("encoding"), context)?;
|
||||||
|
if enc_value.is_undefined() {
|
||||||
|
return Ok(Encoding::Utf8);
|
||||||
|
}
|
||||||
|
return Self::encoding_from_value(&enc_value, context);
|
||||||
|
}
|
||||||
|
Self::encoding_from_value(value, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoding_from_value(value: &JsValue, context: &mut Context) -> JsResult<Encoding> {
|
||||||
|
if value.is_undefined() {
|
||||||
|
return Ok(Encoding::Utf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested = value
|
||||||
|
.to_string(context)?
|
||||||
|
.to_std_string_escaped()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
match requested.as_str() {
|
||||||
|
"utf8" | "utf-8" | "utf" => Ok(Encoding::Utf8),
|
||||||
|
other => Err(JsNativeError::range()
|
||||||
|
.with_message(format!("Unsupported encoding '{other}'"))
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recursive_from_arg(option: Option<&JsValue>, context: &mut Context) -> JsResult<bool> {
|
||||||
|
match option {
|
||||||
|
None => Ok(false),
|
||||||
|
Some(value) if value.is_object() => {
|
||||||
|
let object = value.as_object().expect("checked object");
|
||||||
|
let flag = object.get(JsString::from("recursive"), context)?;
|
||||||
|
Ok(flag.to_boolean())
|
||||||
|
}
|
||||||
|
Some(value) => Ok(value.to_boolean()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_argument(name: &str, method: &str) -> JsError {
|
||||||
|
JsNativeError::typ()
|
||||||
|
.with_message(format!("{method}: missing required argument `{name}`"))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn array_from_values(values: Vec<JsValue>, context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let constructor = context
|
||||||
|
.intrinsics()
|
||||||
|
.constructors()
|
||||||
|
.array()
|
||||||
|
.constructor()
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let array_value = constructor.construct(&[], None, context)?;
|
||||||
|
for (index, value) in values.into_iter().enumerate() {
|
||||||
|
array_value.create_data_property_or_throw(index, value, context)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(array_value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
2
aws_sigma_service/executor/src/builtins/mod.rs
Normal file
2
aws_sigma_service/executor/src/builtins/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub(crate) mod fs;
|
||||||
|
pub(crate) mod sql;
|
||||||
78
aws_sigma_service/executor/src/builtins/prelude.js
Normal file
78
aws_sigma_service/executor/src/builtins/prelude.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
(function () {
|
||||||
|
const EVENT_FILENAME = "./event.json";
|
||||||
|
|
||||||
|
function initRequest() {
|
||||||
|
if (!fs.existsSync(EVENT_FILENAME)) {
|
||||||
|
console.error("Event payload file does not exist:", EVENT_FILENAME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(EVENT_FILENAME, "utf-8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing event payload:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initResponse() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
body: null,
|
||||||
|
setStatus(code) {
|
||||||
|
if (Number.isFinite(code)) {
|
||||||
|
this.statusCode = Math.trunc(code);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
setHeader(name, value) {
|
||||||
|
if (typeof name === "string") {
|
||||||
|
this.headers[name] = value;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
setHeaders(map) {
|
||||||
|
if (isPlainObject(map)) {
|
||||||
|
for (const key in map) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(map, key)) {
|
||||||
|
this.headers[key] = map[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
removeHeader(name) {
|
||||||
|
if (typeof name === "string") {
|
||||||
|
delete this.headers[name];
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.statusCode = 200;
|
||||||
|
this.headers = {};
|
||||||
|
this.body = null;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
send(payload) {
|
||||||
|
this.body = payload;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload) {
|
||||||
|
try {
|
||||||
|
this.body = JSON.stringify(payload);
|
||||||
|
} catch (_error) {
|
||||||
|
this.body = null;
|
||||||
|
}
|
||||||
|
if (!("content-type" in this.headers)) {
|
||||||
|
this.headers["content-type"] = "application/json";
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.req = initRequest();
|
||||||
|
globalThis.res = initResponse();
|
||||||
|
})();
|
||||||
453
aws_sigma_service/executor/src/builtins/sql.rs
Normal file
453
aws_sigma_service/executor/src/builtins/sql.rs
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
use core::cell::RefCell;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||||
|
use boa_engine::error::JsNativeError;
|
||||||
|
use boa_engine::js_string;
|
||||||
|
use boa_engine::native_function::NativeFunction;
|
||||||
|
use boa_engine::object::{JsObject, ObjectInitializer};
|
||||||
|
use boa_engine::property::Attribute;
|
||||||
|
use boa_engine::{Context, JsError, JsResult, JsString, JsValue};
|
||||||
|
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
|
use sqlx::postgres::{PgArguments, PgQueryResult, PgRow, PgTypeInfo};
|
||||||
|
use sqlx::types::Json;
|
||||||
|
use sqlx::{Column, PgPool, Postgres, Row};
|
||||||
|
|
||||||
|
pub(crate) struct Sql;
|
||||||
|
|
||||||
|
static POOL: OnceLock<PgPool> = OnceLock::new();
|
||||||
|
static SCOPE: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
static BLACKLIST: &'static [&str] = &[
|
||||||
|
"pg_authid",
|
||||||
|
"pg_shadow",
|
||||||
|
"pg_user",
|
||||||
|
"pg_roles",
|
||||||
|
"pg_auth_members",
|
||||||
|
"pg_database",
|
||||||
|
"pg_tablespace",
|
||||||
|
"pg_settings",
|
||||||
|
"pg_file_settings",
|
||||||
|
"pg_hba_file_rules",
|
||||||
|
"pg_stat_activity",
|
||||||
|
"pg_stat_replication",
|
||||||
|
"pg_replication_slots",
|
||||||
|
"pg_config",
|
||||||
|
"pg_backend_memory_contexts",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Sql {
|
||||||
|
pub const NAME: JsString = js_string!("sql");
|
||||||
|
|
||||||
|
pub fn init(pool: PgPool, scope: String, context: &mut Context) -> JsObject {
|
||||||
|
POOL.set(pool).unwrap();
|
||||||
|
SCOPE.set(scope).unwrap();
|
||||||
|
|
||||||
|
ObjectInitializer::new(context)
|
||||||
|
.property(
|
||||||
|
js_string!("name"),
|
||||||
|
JsString::from(Self::NAME),
|
||||||
|
Attribute::READONLY,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_async_fn(Self::query),
|
||||||
|
js_string!("query"),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.function(
|
||||||
|
NativeFunction::from_async_fn(Self::execute),
|
||||||
|
js_string!("execute"),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool() -> JsResult<PgPool> {
|
||||||
|
POOL.get().cloned().ok_or_else(|| {
|
||||||
|
JsNativeError::error()
|
||||||
|
.with_message("sql: module not initialized")
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query(
|
||||||
|
_: &JsValue,
|
||||||
|
args: &[JsValue],
|
||||||
|
context: &RefCell<&mut Context>,
|
||||||
|
) -> JsResult<JsValue> {
|
||||||
|
let sql = Self::string_from_arg(args, 0, "query", "sql.query", &mut context.borrow_mut())?;
|
||||||
|
Self::check_query(&*sql)?;
|
||||||
|
let params = Self::params_from_js_value(args.get(1), &mut context.borrow_mut())?;
|
||||||
|
|
||||||
|
let rows = Self::fetch_rows(Self::pool()?, sql, params).await?;
|
||||||
|
Self::rows_to_js(rows, &mut context.borrow_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
_: &JsValue,
|
||||||
|
args: &[JsValue],
|
||||||
|
context: &RefCell<&mut Context>,
|
||||||
|
) -> JsResult<JsValue> {
|
||||||
|
let sql =
|
||||||
|
Self::string_from_arg(args, 0, "query", "sql.execute", &mut context.borrow_mut())?;
|
||||||
|
Self::check_query(&*sql)?;
|
||||||
|
let params = Self::params_from_js_value(args.get(1), &mut context.borrow_mut())?;
|
||||||
|
|
||||||
|
let result = Self::execute_query(Self::pool()?, sql, params).await?;
|
||||||
|
Self::result_to_js(result, &mut context.borrow_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_query(query: &str) -> JsResult<()> {
|
||||||
|
let lowered = query.to_ascii_lowercase();
|
||||||
|
|
||||||
|
for &blacklisted in BLACKLIST {
|
||||||
|
if lowered.contains(blacklisted) {
|
||||||
|
return Err(JsNativeError::error()
|
||||||
|
.with_message(format!(
|
||||||
|
"sql: use of the system table `{blacklisted}` is prohibited"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(scope) = SCOPE.get() {
|
||||||
|
if !scope.is_empty() && !lowered.contains(scope) {
|
||||||
|
return Err(JsNativeError::error()
|
||||||
|
.with_message(format!(
|
||||||
|
"sql: query must only reference the configured scope `{scope}`"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_rows(
|
||||||
|
pool: PgPool,
|
||||||
|
sql: String,
|
||||||
|
params: Vec<SqlParam>,
|
||||||
|
) -> Result<Vec<PgRow>, SqlExecutionError> {
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for param in params {
|
||||||
|
query = param.bind(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
query
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqlExecutionError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_query(
|
||||||
|
pool: PgPool,
|
||||||
|
sql: String,
|
||||||
|
params: Vec<SqlParam>,
|
||||||
|
) -> Result<PgQueryResult, SqlExecutionError> {
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for param in params {
|
||||||
|
query = param.bind(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.execute(&pool).await.map_err(SqlExecutionError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params_from_js_value(
|
||||||
|
arg: Option<&JsValue>,
|
||||||
|
context: &mut Context,
|
||||||
|
) -> JsResult<Vec<SqlParam>> {
|
||||||
|
let Some(value) = arg else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.is_undefined() || value.is_null() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let object = value.as_object().ok_or_else(|| {
|
||||||
|
JsError::from(
|
||||||
|
JsNativeError::typ().with_message("sql: parameters must be provided as an array"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let length_value = object.get(js_string!("length"), context)?;
|
||||||
|
let length_number = length_value.to_number(context)?;
|
||||||
|
let length = if length_number.is_nan() || length_number.is_sign_negative() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
length_number.floor().min(u32::MAX as f64) as u32
|
||||||
|
};
|
||||||
|
let mut params = Vec::with_capacity(length as usize);
|
||||||
|
|
||||||
|
for index in 0..length {
|
||||||
|
let element = object.get(index, context)?;
|
||||||
|
params.push(Self::param_from_js_value(&element, context)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param_from_js_value(value: &JsValue, context: &mut Context) -> JsResult<SqlParam> {
|
||||||
|
if value.is_undefined() || value.is_null() {
|
||||||
|
return Ok(SqlParam::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.is_boolean() {
|
||||||
|
return Ok(SqlParam::Bool(value.to_boolean()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.is_number() {
|
||||||
|
let number = value.to_number(context)?;
|
||||||
|
if number.fract() == 0.0 && number >= i64::MIN as f64 && number <= i64::MAX as f64 {
|
||||||
|
return Ok(SqlParam::Int(number as i64));
|
||||||
|
}
|
||||||
|
return Ok(SqlParam::Float(number));
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.is_string() {
|
||||||
|
return Ok(SqlParam::Text(
|
||||||
|
value.to_string(context)?.to_std_string_escaped(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.is_bigint() {
|
||||||
|
return Ok(SqlParam::Text(
|
||||||
|
value.to_string(context)?.to_std_string_escaped(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.is_symbol() {
|
||||||
|
return Err(JsNativeError::typ()
|
||||||
|
.with_message("sql: Symbols cannot be sent as parameters")
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(json) = value.to_json(context)? else {
|
||||||
|
return Ok(SqlParam::Null);
|
||||||
|
};
|
||||||
|
Ok(SqlParam::Json(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_from_arg(
|
||||||
|
args: &[JsValue],
|
||||||
|
index: usize,
|
||||||
|
name: &str,
|
||||||
|
method: &str,
|
||||||
|
context: &mut Context,
|
||||||
|
) -> JsResult<String> {
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| Self::missing_argument(name, method))?;
|
||||||
|
|
||||||
|
if value.is_undefined() || value.is_null() {
|
||||||
|
return Err(Self::missing_argument(name, method));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(value.to_string(context)?.to_std_string_escaped())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_to_js(rows: Vec<PgRow>, context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let constructor = context
|
||||||
|
.intrinsics()
|
||||||
|
.constructors()
|
||||||
|
.array()
|
||||||
|
.constructor()
|
||||||
|
.clone();
|
||||||
|
let array_value = constructor.construct(&[], None, context)?;
|
||||||
|
|
||||||
|
for (index, row) in rows.iter().enumerate() {
|
||||||
|
let js_row = Self::row_to_object(row, context)?;
|
||||||
|
let row_value: JsValue = js_row.into();
|
||||||
|
array_value.create_data_property_or_throw(index, row_value, context)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(array_value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_object(row: &PgRow, context: &mut Context) -> JsResult<JsObject> {
|
||||||
|
let object = JsObject::with_null_proto();
|
||||||
|
|
||||||
|
for (index, column) in row.columns().iter().enumerate() {
|
||||||
|
let value = Self::value_to_js(row, index, column.type_info(), context)?;
|
||||||
|
object.create_data_property_or_throw(JsString::from(column.name()), value, context)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_js(
|
||||||
|
row: &PgRow,
|
||||||
|
index: usize,
|
||||||
|
type_info: &PgTypeInfo,
|
||||||
|
context: &mut Context,
|
||||||
|
) -> JsResult<JsValue> {
|
||||||
|
let type_name = type_info.to_string().to_ascii_uppercase();
|
||||||
|
|
||||||
|
macro_rules! optional_number {
|
||||||
|
($ty:ty) => {{
|
||||||
|
let value: Option<$ty> = row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|inner| JsValue::new(inner as f64))
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
match type_name.as_str() {
|
||||||
|
"BOOL" => {
|
||||||
|
let value: Option<bool> = row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value.map(JsValue::new).unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"INT2" | "INT4" => optional_number!(i32),
|
||||||
|
"INT8" => optional_number!(i64),
|
||||||
|
"FLOAT4" | "FLOAT8" => {
|
||||||
|
let value: Option<f64> = row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value.map(JsValue::new).unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"NUMERIC" | "DECIMAL" => {
|
||||||
|
let value: Option<f64> = row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value.map(JsValue::new).unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"TEXT" | "VARCHAR" | "BPCHAR" | "CHAR" | "UUID" | "INET" | "CIDR" => {
|
||||||
|
let value: Option<String> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|text| JsValue::from(JsString::from(text)))
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"JSON" | "JSONB" => {
|
||||||
|
let value: Option<JsonValue> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
match value {
|
||||||
|
Some(json) => JsValue::from_json(&json, context),
|
||||||
|
None => Ok(JsValue::null()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"TIMESTAMP" => {
|
||||||
|
let value: Option<NaiveDateTime> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|ts| {
|
||||||
|
let dt = DateTime::<Utc>::from_naive_utc_and_offset(ts, Utc);
|
||||||
|
JsValue::from(JsString::from(dt.to_rfc3339()))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"TIMESTAMPTZ" => {
|
||||||
|
let value: Option<DateTime<Utc>> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|ts| JsValue::from(JsString::from(ts.to_rfc3339())))
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"DATE" => {
|
||||||
|
let value: Option<NaiveDate> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|date| JsValue::from(JsString::from(date.to_string())))
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"TIME" | "TIMETZ" => {
|
||||||
|
let value: Option<NaiveTime> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|time| JsValue::from(JsString::from(time.to_string())))
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
"BYTEA" => {
|
||||||
|
let value: Option<Vec<u8>> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|bytes| {
|
||||||
|
let encoded = BASE64_STANDARD.encode(bytes);
|
||||||
|
JsValue::from(JsString::from(encoded))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let value: Option<String> =
|
||||||
|
row.try_get(index).map_err(Self::column_access_error)?;
|
||||||
|
Ok(value
|
||||||
|
.map(|text| JsValue::from(JsString::from(text)))
|
||||||
|
.unwrap_or_else(JsValue::null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_to_js(result: PgQueryResult, context: &mut Context) -> JsResult<JsValue> {
|
||||||
|
let mut initializer = ObjectInitializer::new(context);
|
||||||
|
initializer.property(
|
||||||
|
js_string!("rowsAffected"),
|
||||||
|
result.rows_affected() as f64,
|
||||||
|
Attribute::READONLY | Attribute::ENUMERABLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(initializer.build().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_argument(name: &str, method: &str) -> JsError {
|
||||||
|
JsNativeError::typ()
|
||||||
|
.with_message(format!("{method}: missing required argument `{name}`"))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column_access_error(err: sqlx::Error) -> JsError {
|
||||||
|
JsNativeError::error()
|
||||||
|
.with_message(format!("sql: failed to read column value: {err}"))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum SqlParam {
|
||||||
|
Int(i64),
|
||||||
|
Float(f64),
|
||||||
|
Bool(bool),
|
||||||
|
Text(String),
|
||||||
|
Json(JsonValue),
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlParam {
|
||||||
|
fn bind<'q>(
|
||||||
|
self,
|
||||||
|
query: sqlx::query::Query<'q, Postgres, PgArguments>,
|
||||||
|
) -> sqlx::query::Query<'q, Postgres, PgArguments> {
|
||||||
|
match self {
|
||||||
|
SqlParam::Int(value) => query.bind(value),
|
||||||
|
SqlParam::Float(value) => query.bind(value),
|
||||||
|
SqlParam::Bool(value) => query.bind(value),
|
||||||
|
SqlParam::Text(value) => query.bind(value),
|
||||||
|
SqlParam::Json(value) => query.bind(Json(value)),
|
||||||
|
SqlParam::Null => query.bind(Option::<String>::None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum SqlExecutionError {
|
||||||
|
Sql(sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for SqlExecutionError {
|
||||||
|
fn from(value: sqlx::Error) -> Self {
|
||||||
|
Self::Sql(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlExecutionError {
|
||||||
|
fn into_js_error(self, method: &str) -> JsError {
|
||||||
|
match self {
|
||||||
|
Self::Sql(err) => JsNativeError::error()
|
||||||
|
.with_message(format!("{method}: database error: {err}"))
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SqlExecutionError> for JsError {
|
||||||
|
fn from(value: SqlExecutionError) -> Self {
|
||||||
|
value.into_js_error("sql")
|
||||||
|
}
|
||||||
|
}
|
||||||
28
aws_sigma_service/executor/src/logger.rs
Normal file
28
aws_sigma_service/executor/src/logger.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use boa_engine::{Context, JsResult, Trace};
|
||||||
|
use boa_gc::Finalize;
|
||||||
|
use boa_runtime::{ConsoleState, Logger};
|
||||||
|
|
||||||
|
#[derive(Trace, Finalize, Debug)]
|
||||||
|
pub(crate) struct StderrLogger;
|
||||||
|
|
||||||
|
impl Logger for StderrLogger {
|
||||||
|
fn log(&self, msg: String, _state: &ConsoleState, _context: &mut Context) -> JsResult<()> {
|
||||||
|
eprintln!("[LOG] {}", msg);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self, msg: String, _state: &ConsoleState, _context: &mut Context) -> JsResult<()> {
|
||||||
|
eprintln!("[INFO] {}", msg);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn(&self, msg: String, _state: &ConsoleState, _context: &mut Context) -> JsResult<()> {
|
||||||
|
eprintln!("[WARN] {}", msg);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(&self, msg: String, _state: &ConsoleState, _context: &mut Context) -> JsResult<()> {
|
||||||
|
eprintln!("[ERROR] {}", msg);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
217
aws_sigma_service/executor/src/main.rs
Normal file
217
aws_sigma_service/executor/src/main.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
use core::cell::RefCell;
|
||||||
|
use core::u64;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::process;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use boa_engine::context::ContextBuilder;
|
||||||
|
use boa_engine::job::JobExecutor;
|
||||||
|
use boa_engine::property::Attribute;
|
||||||
|
use boa_engine::vm::RuntimeLimits;
|
||||||
|
use boa_engine::{Context, Script, Source};
|
||||||
|
use boa_runtime::fetch::BlockingReqwestFetcher;
|
||||||
|
use boa_runtime::{Console, fetch};
|
||||||
|
use clap::Parser;
|
||||||
|
use clap::builder::NonEmptyStringValueParser;
|
||||||
|
use rlimit::Resource;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
|
use crate::builtins::fs::Fs;
|
||||||
|
use crate::builtins::sql::Sql;
|
||||||
|
use crate::logger::StderrLogger;
|
||||||
|
use crate::queue::Queue;
|
||||||
|
|
||||||
|
pub(crate) mod builtins;
|
||||||
|
pub(crate) mod logger;
|
||||||
|
pub(crate) mod queue;
|
||||||
|
|
||||||
|
static JS_PRELUDE: &str = include_str!("builtins/prelude.js");
|
||||||
|
|
||||||
|
#[derive(Parser, Debug, Clone)]
|
||||||
|
struct Args {
|
||||||
|
#[clap(long, help = "Set the recursion limit")]
|
||||||
|
recursion_limit: Option<usize>,
|
||||||
|
#[clap(long, help = "Set the loop iteration limit")]
|
||||||
|
loop_limit: Option<u64>,
|
||||||
|
#[clap(long, help = "Set the virtual memory limit (in megabytes)")]
|
||||||
|
virtual_memory_limit: Option<u64>,
|
||||||
|
#[clap(long, help = "Enable file system access", default_value_t = false)]
|
||||||
|
enable_fs: bool,
|
||||||
|
#[clap(long, help = "Enable network access", default_value_t = false)]
|
||||||
|
enable_net: bool,
|
||||||
|
#[clap(long, help = "Enables PostgreSQL acress limited to single table")]
|
||||||
|
enable_sql: Option<String>,
|
||||||
|
#[clap(
|
||||||
|
help = "Input filename. Use - to read from stdin",
|
||||||
|
value_parser = NonEmptyStringValueParser::new(),
|
||||||
|
)]
|
||||||
|
filename: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
struct DatabaseConfig {
|
||||||
|
postgres_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let script_source = read_script(&args)?;
|
||||||
|
let (mut context, queue) = init_context(&args).await?;
|
||||||
|
|
||||||
|
let prelude = Script::parse(
|
||||||
|
Source::from_bytes(JS_PRELUDE.as_bytes()),
|
||||||
|
None,
|
||||||
|
&mut context,
|
||||||
|
)
|
||||||
|
.map_err(|_| anyhow!("Failed to parse prelude"))?;
|
||||||
|
|
||||||
|
let script = Script::parse(
|
||||||
|
Source::from_bytes(script_source.as_bytes()),
|
||||||
|
None,
|
||||||
|
&mut context,
|
||||||
|
)
|
||||||
|
.map_err(|_| anyhow!("Failed to parse input script"))?;
|
||||||
|
|
||||||
|
prelude
|
||||||
|
.evaluate_async(&mut context)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Prelude: Uncaught {e}"))?;
|
||||||
|
|
||||||
|
limit_additional_memory(&args)?;
|
||||||
|
|
||||||
|
script
|
||||||
|
.evaluate_async(&mut context)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Uncaught {e}"))?;
|
||||||
|
|
||||||
|
queue
|
||||||
|
.run_jobs_async(&RefCell::new(&mut context))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Uncaught {e}"))?;
|
||||||
|
|
||||||
|
unlimit_memory()?;
|
||||||
|
|
||||||
|
let serialize_code = r#"
|
||||||
|
JSON.stringify(globalThis.res)
|
||||||
|
"#;
|
||||||
|
|
||||||
|
match context.eval(Source::from_bytes(serialize_code.as_bytes())) {
|
||||||
|
Ok(result) => match result.to_string(&mut context) {
|
||||||
|
Ok(output) => println!("{}", output.to_std_string_lossy()),
|
||||||
|
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Unexpected serialization output: {err}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Uncaught {err}");
|
||||||
|
process::exit(129);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit_additional_memory(args: &Args) -> anyhow::Result<()> {
|
||||||
|
let usage = memory_stats::memory_stats().context("Failed to obtain memory usage statistics")?;
|
||||||
|
|
||||||
|
if let Some(limit_mb) = args.virtual_memory_limit {
|
||||||
|
let limit = limit_mb * 1024 * 1024 + usage.virtual_mem as u64;
|
||||||
|
|
||||||
|
Resource::AS
|
||||||
|
.set(limit, limit)
|
||||||
|
.context("Failed to enforce virtual memory limit")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlimit_memory() -> anyhow::Result<()> {
|
||||||
|
Resource::AS
|
||||||
|
.set(u64::MAX, u64::MAX)
|
||||||
|
.context("Failed to remove memory limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_runtime_limits(context: &mut Context, args: &Args) {
|
||||||
|
context.set_runtime_limits(get_runtime_limits(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_runtime_limits(args: &Args) -> RuntimeLimits {
|
||||||
|
let mut limits = RuntimeLimits::default();
|
||||||
|
|
||||||
|
if let Some(recursion_limit) = args.recursion_limit {
|
||||||
|
limits.set_recursion_limit(recursion_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(loop_limit) = args.loop_limit {
|
||||||
|
limits.set_loop_iteration_limit(loop_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
limits
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn init_builtins(context: &mut Context, args: &Args) -> anyhow::Result<()> {
|
||||||
|
let console = Console::init_with_logger(StderrLogger, context);
|
||||||
|
context
|
||||||
|
.register_global_property(Console::NAME, console, Attribute::all())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if args.enable_fs {
|
||||||
|
let fs = builtins::fs::Fs::init(context);
|
||||||
|
context
|
||||||
|
.register_global_property(Fs::NAME, fs, Attribute::all())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.enable_net {
|
||||||
|
let fetcher = BlockingReqwestFetcher::default();
|
||||||
|
fetch::register(fetcher, None, context).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref scope) = args.enable_sql {
|
||||||
|
let db_config = envy::from_env::<DatabaseConfig>()?;
|
||||||
|
let pool = PgPoolOptions::default()
|
||||||
|
.min_connections(0)
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(&db_config.postgres_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let sql = Sql::init(pool, scope.clone(), context);
|
||||||
|
context
|
||||||
|
.register_global_property(Sql::NAME, sql, Attribute::all())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn init_context(args: &Args) -> anyhow::Result<(Context, Rc<Queue>)> {
|
||||||
|
let queue = Rc::new(Queue::new());
|
||||||
|
let mut context = ContextBuilder::new()
|
||||||
|
.job_executor(queue.clone())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
init_runtime_limits(&mut context, args);
|
||||||
|
init_builtins(&mut context, args).await?;
|
||||||
|
|
||||||
|
Ok((context, queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_script(args: &Args) -> anyhow::Result<String> {
|
||||||
|
if args.filename == "-" {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_to_string(&mut buffer)
|
||||||
|
.context("Failed to read input script from stdin")?;
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
} else {
|
||||||
|
std::fs::read_to_string(&args.filename).context("Failed to read the script file")
|
||||||
|
}
|
||||||
|
}
|
||||||
112
aws_sigma_service/executor/src/queue.rs
Normal file
112
aws_sigma_service/executor/src/queue.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use core::cell::RefCell;
|
||||||
|
use core::ops::DerefMut;
|
||||||
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use boa_engine::context::time::JsInstant;
|
||||||
|
use boa_engine::job::{GenericJob, Job, JobExecutor, NativeAsyncJob, PromiseJob, TimeoutJob};
|
||||||
|
use boa_engine::{Context, JsResult};
|
||||||
|
use futures_concurrency::future::FutureGroup;
|
||||||
|
use futures_lite::{StreamExt, future};
|
||||||
|
use tokio::task;
|
||||||
|
|
||||||
|
pub(crate) struct Queue {
|
||||||
|
async_jobs: RefCell<VecDeque<NativeAsyncJob>>,
|
||||||
|
promise_jobs: RefCell<VecDeque<PromiseJob>>,
|
||||||
|
timeout_jobs: RefCell<BTreeMap<JsInstant, TimeoutJob>>,
|
||||||
|
generic_jobs: RefCell<VecDeque<GenericJob>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Queue {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
async_jobs: RefCell::default(),
|
||||||
|
promise_jobs: RefCell::default(),
|
||||||
|
timeout_jobs: RefCell::default(),
|
||||||
|
generic_jobs: RefCell::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn drain_timeout_jobs(&self, context: &mut Context) {
|
||||||
|
let now = context.clock().now();
|
||||||
|
|
||||||
|
let mut timeouts_borrow = self.timeout_jobs.borrow_mut();
|
||||||
|
let mut jobs_to_keep = timeouts_borrow.split_off(&now);
|
||||||
|
jobs_to_keep.retain(|_, job| !job.is_cancelled());
|
||||||
|
let jobs_to_run = std::mem::replace(timeouts_borrow.deref_mut(), jobs_to_keep);
|
||||||
|
drop(timeouts_borrow);
|
||||||
|
|
||||||
|
for job in jobs_to_run.into_values() {
|
||||||
|
if let Err(e) = job.call(context) {
|
||||||
|
eprintln!("Uncaught {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn drain_jobs(&self, context: &mut Context) {
|
||||||
|
self.drain_timeout_jobs(context);
|
||||||
|
|
||||||
|
let job = self.generic_jobs.borrow_mut().pop_front();
|
||||||
|
if let Some(generic) = job
|
||||||
|
&& let Err(err) = generic.call(context)
|
||||||
|
{
|
||||||
|
eprintln!("Uncaught {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobs = std::mem::take(&mut *self.promise_jobs.borrow_mut());
|
||||||
|
for job in jobs {
|
||||||
|
if let Err(e) = job.call(context) {
|
||||||
|
eprintln!("Uncaught {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.clear_kept_objects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobExecutor for Queue {
|
||||||
|
fn enqueue_job(self: Rc<Self>, job: Job, context: &mut Context) {
|
||||||
|
match job {
|
||||||
|
Job::PromiseJob(job) => self.promise_jobs.borrow_mut().push_back(job),
|
||||||
|
Job::AsyncJob(job) => self.async_jobs.borrow_mut().push_back(job),
|
||||||
|
Job::TimeoutJob(t) => {
|
||||||
|
let now = context.clock().now();
|
||||||
|
self.timeout_jobs.borrow_mut().insert(now + t.timeout(), t);
|
||||||
|
}
|
||||||
|
Job::GenericJob(g) => self.generic_jobs.borrow_mut().push_back(g),
|
||||||
|
_ => panic!("unsupported job type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_jobs(self: Rc<Self>, context: &mut Context) -> JsResult<()> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_time()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
task::LocalSet::default().block_on(&runtime, self.run_jobs_async(&RefCell::new(context)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_jobs_async(self: Rc<Self>, context: &RefCell<&mut Context>) -> JsResult<()> {
|
||||||
|
let mut group = FutureGroup::new();
|
||||||
|
loop {
|
||||||
|
for job in std::mem::take(&mut *self.async_jobs.borrow_mut()) {
|
||||||
|
group.insert(job.call(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
if group.is_empty()
|
||||||
|
&& self.promise_jobs.borrow().is_empty()
|
||||||
|
&& self.timeout_jobs.borrow().is_empty()
|
||||||
|
&& self.generic_jobs.borrow().is_empty()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Err(err)) = future::poll_once(group.next()).await.flatten() {
|
||||||
|
eprintln!("Uncaught {err}");
|
||||||
|
};
|
||||||
|
|
||||||
|
self.drain_jobs(&mut context.borrow_mut());
|
||||||
|
task::yield_now().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
aws_sigma_service/nginx.conf
Normal file
54
aws_sigma_service/nginx.conf
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
#access_log /var/log/nginx/host.access.log main;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error_page 404 /404.html;
|
||||||
|
|
||||||
|
# redirect server error pages to the static page /50x.html
|
||||||
|
#
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
|
||||||
|
#
|
||||||
|
#location ~ \.php$ {
|
||||||
|
# proxy_pass http://127.0.0.1;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
|
||||||
|
#
|
||||||
|
#location ~ \.php$ {
|
||||||
|
# root html;
|
||||||
|
# fastcgi_pass 127.0.0.1:9000;
|
||||||
|
# fastcgi_index index.php;
|
||||||
|
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
|
||||||
|
# include fastcgi_params;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# deny access to .htaccess files, if Apache's document root
|
||||||
|
# concurs with nginx's one
|
||||||
|
#
|
||||||
|
#location ~ /\.ht {
|
||||||
|
# deny all;
|
||||||
|
#}
|
||||||
|
}
|
||||||
|
|
||||||
15
aws_sigma_service/package.json
Normal file
15
aws_sigma_service/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "sigma",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"workspaces": [
|
||||||
|
"./api",
|
||||||
|
"./common",
|
||||||
|
"./panel"
|
||||||
|
],
|
||||||
|
"version": "0.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@parcel/packager-ts": "2.16.1",
|
||||||
|
"@parcel/transformer-inline-string": "2.16.1"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.9.2"
|
||||||
|
}
|
||||||
10
aws_sigma_service/panel/.parcelrc
Normal file
10
aws_sigma_service/panel/.parcelrc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"@parcel/config-default"
|
||||||
|
],
|
||||||
|
"transformers": {
|
||||||
|
"bundle-text:*": [
|
||||||
|
"@parcel/transformer-inline-string"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
31
aws_sigma_service/panel/package.json
Normal file
31
aws_sigma_service/panel/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@sigma/panel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"source": "src/index.html",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "parcel",
|
||||||
|
"build": "parcel build",
|
||||||
|
"format": "prettier -w src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@mui/joy": "5.0.0-beta.52",
|
||||||
|
"@sigma/common": "workspace:^",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"lucide-react": "^0.554.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router": "^7.9.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"parcel": "^2.14.0",
|
||||||
|
"prettier": "^3.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
aws_sigma_service/panel/src/app.tsx
Normal file
36
aws_sigma_service/panel/src/app.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from "react-router";
|
||||||
|
import { AuthProvider } from "./context/auth-context";
|
||||||
|
import { Layout } from "./pages/layout";
|
||||||
|
import { Login } from "./pages/login";
|
||||||
|
import { Dashboard } from "./pages/dashboard";
|
||||||
|
import { CreateProjectPage } from "./pages/projects/new";
|
||||||
|
import { ProjectDetailPage } from "./pages/projects/detail";
|
||||||
|
import { CreateFunctionPage } from "./pages/projects/functions/new";
|
||||||
|
import { FunctionDetailPage } from "./pages/projects/functions/detail";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="projects">
|
||||||
|
<Route path="new" element={<CreateProjectPage />} />
|
||||||
|
<Route path=":projectId" element={<ProjectDetailPage />} />
|
||||||
|
<Route
|
||||||
|
path=":projectId/functions/new"
|
||||||
|
element={<CreateFunctionPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=":projectId/functions/:functionId"
|
||||||
|
element={<FunctionDetailPage />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="login" element={<Login />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||||
|
|
||||||
|
export function formatNumber(value: number) {
|
||||||
|
return numberFormatter.format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Box, Button, Card, Chip, Stack, Table, Typography } from "@mui/joy";
|
||||||
|
import { Layers3, PenSquare, Plus } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
import { formatNumber } from "./number-format";
|
||||||
|
import type { ProjectSummary } from "./types";
|
||||||
|
|
||||||
|
export type ProjectsListCardProps = {
|
||||||
|
projects: ProjectSummary[];
|
||||||
|
onCreateProject: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectsListCard({
|
||||||
|
projects,
|
||||||
|
onCreateProject,
|
||||||
|
}: ProjectsListCardProps) {
|
||||||
|
const hasProjects = projects.length > 0;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleOpenProject = (projectId: string) => {
|
||||||
|
navigate(`/projects/${projectId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProject = (projectId: string) => {
|
||||||
|
navigate(`/projects/${projectId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ height: "100%", p: 0 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{
|
||||||
|
px: 2.5,
|
||||||
|
py: 2,
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.25}>
|
||||||
|
<Layers3 size={18} />
|
||||||
|
<Box>
|
||||||
|
<Typography level="title-sm" fontWeight={600}>
|
||||||
|
Projects
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-xs" textColor="neutral.500">
|
||||||
|
Overview of your deployed workloads
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<Chip size="sm" variant="soft">
|
||||||
|
{projects.length} total
|
||||||
|
</Chip>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<Plus size={16} />}
|
||||||
|
onClick={onCreateProject}
|
||||||
|
>
|
||||||
|
New project
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{hasProjects ? (
|
||||||
|
<Table
|
||||||
|
size="sm"
|
||||||
|
stickyHeader
|
||||||
|
sx={{
|
||||||
|
"--TableCell-headBackground": "transparent",
|
||||||
|
"--TableCell-paddingX": "16px",
|
||||||
|
"--TableCell-paddingY": "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: "35%" }}>Name</th>
|
||||||
|
<th>Functions</th>
|
||||||
|
<th>Invocations</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
<th>CPU usage</th>
|
||||||
|
<th style={{ width: "12%" }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((project) => {
|
||||||
|
const functionsCount = project.functions?.length ?? 0;
|
||||||
|
const invocations = project.functionsInvocationCount ?? 0;
|
||||||
|
const errors = project.functionsErrorCount ?? 0;
|
||||||
|
const quota = project.cpuTimeQuotaMsPerMinute ?? 0;
|
||||||
|
const used = project.cpuTimeUsedMs ?? 0;
|
||||||
|
const cpuPercent = quota
|
||||||
|
? Math.min(100, (used / quota) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={project._id}
|
||||||
|
onClick={() => handleOpenProject(project._id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleOpenProject(project._id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<Stack>
|
||||||
|
<Typography level="body-sm" fontWeight={600}>
|
||||||
|
{project.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="sm" variant="soft" color="neutral">
|
||||||
|
{project.slug}
|
||||||
|
</Chip>
|
||||||
|
</Stack>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography level="body-sm" fontWeight={500}>
|
||||||
|
{formatNumber(functionsCount)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography level="body-sm" fontWeight={500}>
|
||||||
|
{formatNumber(invocations)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography
|
||||||
|
level="body-sm"
|
||||||
|
fontWeight={500}
|
||||||
|
color={errors ? "danger" : undefined}
|
||||||
|
>
|
||||||
|
{formatNumber(errors)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography level="body-sm" fontWeight={500}>
|
||||||
|
{cpuPercent.toFixed(1)}%
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Stack direction="row" gap={1} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
startDecorator={<PenSquare size={14} />}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleEditProject(project._id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Stack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
sx={{ py: 6, px: 2 }}
|
||||||
|
gap={0.75}
|
||||||
|
>
|
||||||
|
<Typography level="title-sm">No projects yet</Typography>
|
||||||
|
<Typography
|
||||||
|
level="body-sm"
|
||||||
|
textColor="neutral.500"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
Spin up your first project to start deploying functions.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<Plus size={16} />}
|
||||||
|
onClick={onCreateProject}
|
||||||
|
>
|
||||||
|
Create project
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Card, LinearProgress, Stack, Typography } from "@mui/joy";
|
||||||
|
import { Activity } from "lucide-react";
|
||||||
|
|
||||||
|
import { DashboardSummary } from "./types";
|
||||||
|
|
||||||
|
export function ServerLoadCard({ summary }: { summary: DashboardSummary }) {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<Stack gap={1.5}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
|
<Activity size={18} />
|
||||||
|
<Typography level="title-sm" fontWeight={600}>
|
||||||
|
Current server load
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography level="h1" fontSize="2.5rem">
|
||||||
|
{summary.serverLoadPercent.toFixed(1)}%
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
determinate
|
||||||
|
value={summary.serverLoadPercent}
|
||||||
|
color={
|
||||||
|
summary.serverLoadPercent > 85
|
||||||
|
? "danger"
|
||||||
|
: summary.serverLoadPercent > 60
|
||||||
|
? "warning"
|
||||||
|
: "success"
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "xl",
|
||||||
|
"&::before": {
|
||||||
|
transitionProperty: "inline-size",
|
||||||
|
transitionDuration: "320ms",
|
||||||
|
msTransitionTimingFunction: "ease-in-out",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Card, Typography } from "@mui/joy";
|
||||||
|
|
||||||
|
export type StatCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
caption: string;
|
||||||
|
statusColor?: "neutral" | "success" | "warning" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
caption,
|
||||||
|
statusColor = "neutral",
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="soft" color={statusColor}>
|
||||||
|
<Typography level="title-sm" textColor="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="h2" mt={0.5}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
{caption}
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Grid } from "@mui/joy";
|
||||||
|
|
||||||
|
import { StatCard } from "./stat-card";
|
||||||
|
import { DashboardSummary } from "./types";
|
||||||
|
import { formatNumber } from "./number-format";
|
||||||
|
|
||||||
|
export function TopStatsGrid({ summary }: { summary: DashboardSummary }) {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid xs={12} sm={6} lg={3}>
|
||||||
|
<StatCard
|
||||||
|
label="Active projects"
|
||||||
|
value={formatNumber(summary.totalProjects)}
|
||||||
|
caption="Projects receiving traffic"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} lg={3}>
|
||||||
|
<StatCard
|
||||||
|
label="Functions"
|
||||||
|
value={formatNumber(summary.totalFunctions)}
|
||||||
|
caption="Deployed across all projects"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} lg={3}>
|
||||||
|
<StatCard
|
||||||
|
label="Invocations"
|
||||||
|
value={formatNumber(summary.totalInvocations)}
|
||||||
|
caption="Across all projects"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} sm={6} lg={3}>
|
||||||
|
<StatCard
|
||||||
|
label="Errors"
|
||||||
|
value={formatNumber(summary.totalErrors)}
|
||||||
|
caption="Functions reporting failures"
|
||||||
|
statusColor={summary.totalErrors ? "danger" : "success"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type DashboardSummary = {
|
||||||
|
totalProjects: number;
|
||||||
|
totalFunctions: number;
|
||||||
|
totalInvocations: number;
|
||||||
|
totalErrors: number;
|
||||||
|
quotaUsed: number;
|
||||||
|
quotaTotal: number;
|
||||||
|
serverLoadPercent: number;
|
||||||
|
};
|
||||||
1
aws_sigma_service/panel/src/config.ts
Normal file
1
aws_sigma_service/panel/src/config.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const BACKEND_BASE = process.env["BACKEND_BASE"] || "/";
|
||||||
235
aws_sigma_service/panel/src/context/auth-context.tsx
Normal file
235
aws_sigma_service/panel/src/context/auth-context.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { apiClient, configureApiAuth } from "../lib/api";
|
||||||
|
import { UserDto } from "@sigma/common";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "sigma.auth";
|
||||||
|
|
||||||
|
type LoginDto = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterDto = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: UserDto | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
login: (payload: LoginDto) => Promise<void>;
|
||||||
|
register: (payload: RegisterDto) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SavedState = Pick<AuthContextValue, "user" | "token">;
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
function readStoredAuth() {
|
||||||
|
try {
|
||||||
|
const json = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!json) return null;
|
||||||
|
|
||||||
|
return JSON.parse(json) as SavedState;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to parse stored auth", error);
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [user, setUser] = useState<UserDto | null>(
|
||||||
|
() => readStoredAuth()?.user ?? null
|
||||||
|
);
|
||||||
|
const [token, setToken] = useState<string | null>(
|
||||||
|
() => readStoredAuth()?.token ?? null
|
||||||
|
);
|
||||||
|
const tokenRef = useRef(readStoredAuth()?.token ?? null);
|
||||||
|
const logoutRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const persistAuth = useCallback((newUser: UserDto, newToken: string) => {
|
||||||
|
setUser(newUser);
|
||||||
|
setToken(newToken);
|
||||||
|
tokenRef.current = newToken;
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ user: newUser, token: newToken })
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAuth = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
tokenRef.current = null;
|
||||||
|
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProfile = useCallback(
|
||||||
|
async ({ tokenOverride }: { tokenOverride?: string } = {}) => {
|
||||||
|
const activeToken = tokenOverride ?? token;
|
||||||
|
if (!activeToken) {
|
||||||
|
throw new Error("Missing auth token for profile request");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers =
|
||||||
|
tokenOverride !== undefined
|
||||||
|
? { Authorization: `Bearer ${activeToken}` }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { data } = await apiClient.get<UserDto>("/users/me", {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
persistAuth(data, activeToken);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[persistAuth, token]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearAuth();
|
||||||
|
|
||||||
|
navigate("/login", {
|
||||||
|
replace: true,
|
||||||
|
state: { from: location.pathname },
|
||||||
|
});
|
||||||
|
}, [clearAuth, navigate, location.pathname]);
|
||||||
|
|
||||||
|
logoutRef.current = logout;
|
||||||
|
|
||||||
|
configureApiAuth({
|
||||||
|
tokenProvider: () => tokenRef.current,
|
||||||
|
onUnauthorized: () => logoutRef.current?.(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticate = useCallback(
|
||||||
|
async ({ username, password }: LoginDto) => {
|
||||||
|
const { data } = await apiClient.post<{ auth_token?: string }>(
|
||||||
|
"/auth/login",
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const newToken = data?.auth_token;
|
||||||
|
if (!newToken) {
|
||||||
|
throw new Error("Login response missing auth token");
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchProfile({ tokenOverride: newToken });
|
||||||
|
},
|
||||||
|
[fetchProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (payload: LoginDto) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authenticate(payload);
|
||||||
|
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authenticate, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (payload: RegisterDto) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post("/users", payload);
|
||||||
|
await authenticate(payload);
|
||||||
|
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authenticate, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
fetchProfile()
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, user, fetchProfile, logout]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: Boolean(user && token),
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[user, token, loading, login, register, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequireAuth() {
|
||||||
|
const context = useAuth();
|
||||||
|
if (!context.isAuthenticated) {
|
||||||
|
throw new Error("User is not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
12
aws_sigma_service/panel/src/index.html
Normal file
12
aws_sigma_service/panel/src/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AWS Sigma</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
aws_sigma_service/panel/src/index.tsx
Normal file
10
aws_sigma_service/panel/src/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="./parcel.d.ts" />
|
||||||
|
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./app";
|
||||||
|
|
||||||
|
import "@fontsource/inter";
|
||||||
|
|
||||||
|
let container = document.getElementById("app")!;
|
||||||
|
let root = createRoot(container);
|
||||||
|
root.render(<App />);
|
||||||
43
aws_sigma_service/panel/src/lib/api.ts
Normal file
43
aws_sigma_service/panel/src/lib/api.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { BACKEND_BASE } from "../config";
|
||||||
|
|
||||||
|
type TokenSupplier = () => string | null;
|
||||||
|
type UnauthorizedHandler = () => void;
|
||||||
|
|
||||||
|
let tokenSupplier: TokenSupplier = () => null;
|
||||||
|
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: `${BACKEND_BASE}api/v1`,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = tokenSupplier();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers ?? {};
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
unauthorizedHandler?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function configureApiAuth(options: {
|
||||||
|
tokenProvider: TokenSupplier;
|
||||||
|
onUnauthorized?: UnauthorizedHandler;
|
||||||
|
}) {
|
||||||
|
tokenSupplier = options.tokenProvider;
|
||||||
|
unauthorizedHandler = options.onUnauthorized ?? null;
|
||||||
|
}
|
||||||
21
aws_sigma_service/panel/src/lib/editor.ts
Normal file
21
aws_sigma_service/panel/src/lib/editor.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { useMonaco } from "@monaco-editor/react";
|
||||||
|
import typings from "bundle-text:../../../executor/src/builtins/builtins.d.ts";
|
||||||
|
|
||||||
|
const TYPINGS_URI = "ts:platform.d.ts";
|
||||||
|
|
||||||
|
export function configureEditor(
|
||||||
|
monaco: NonNullable<ReturnType<typeof useMonaco>>
|
||||||
|
) {
|
||||||
|
monaco.typescript.javascriptDefaults.setCompilerOptions({
|
||||||
|
target: monaco.typescript.ScriptTarget.ES2015,
|
||||||
|
allowNonTsExtensions: true, // Fix the very weird bug. Don't touch. EVER.
|
||||||
|
});
|
||||||
|
|
||||||
|
monaco.typescript.javascriptDefaults.addExtraLib(typings, TYPINGS_URI);
|
||||||
|
|
||||||
|
const uri = monaco.Uri.parse(TYPINGS_URI);
|
||||||
|
|
||||||
|
if (!monaco.editor.getModel(uri)) {
|
||||||
|
monaco.editor.createModel(typings, "typescript");
|
||||||
|
}
|
||||||
|
}
|
||||||
186
aws_sigma_service/panel/src/pages/dashboard.tsx
Normal file
186
aws_sigma_service/panel/src/pages/dashboard.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CircularProgress,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { useAuth } from "../context/auth-context";
|
||||||
|
import { TopStatsGrid } from "../components/dashboard/top-stats-grid";
|
||||||
|
import { ServerLoadCard } from "../components/dashboard/server-load-card";
|
||||||
|
import { ProjectsListCard } from "../components/dashboard/projects-list-card";
|
||||||
|
import { CpuInfoDto, ProjectDto } from "@sigma/common";
|
||||||
|
import { DashboardSummary } from "../components/dashboard/types";
|
||||||
|
|
||||||
|
function buildDashboardSummary(
|
||||||
|
projects: ProjectDto[],
|
||||||
|
cpuUsage: number
|
||||||
|
): DashboardSummary {
|
||||||
|
const totals = projects.reduce(
|
||||||
|
(acc, project) => {
|
||||||
|
acc.totalFunctions += project.functions?.length ?? 0;
|
||||||
|
acc.totalInvocations += project.functionsInvocationCount ?? 0;
|
||||||
|
acc.totalErrors += project.functionsErrorCount ?? 0;
|
||||||
|
acc.quotaTotal += project.cpuTimeQuotaMsPerMinute ?? 0;
|
||||||
|
acc.quotaUsed += project.cpuTimeUsedMs ?? 0;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
totalFunctions: 0,
|
||||||
|
totalInvocations: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
quotaTotal: 0,
|
||||||
|
quotaUsed: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProjects: projects.length,
|
||||||
|
totalFunctions: totals.totalFunctions,
|
||||||
|
totalInvocations: totals.totalInvocations,
|
||||||
|
totalErrors: totals.totalErrors,
|
||||||
|
quotaUsed: totals.quotaUsed,
|
||||||
|
quotaTotal: totals.quotaTotal,
|
||||||
|
serverLoadPercent: cpuUsage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||||
|
const [cpuUsage, setCpuUsage] = useState<number>(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<ProjectDto[]>("/projects");
|
||||||
|
setProjects(data);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Unable to load projects right now"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCpuUsage = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<CpuInfoDto>("/health/cpu");
|
||||||
|
|
||||||
|
setCpuUsage(data.usage);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch CPU usage:", err);
|
||||||
|
}
|
||||||
|
}, [setCpuUsage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects();
|
||||||
|
}, [fetchProjects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => updateCpuUsage(), 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [updateCpuUsage]);
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => buildDashboardSummary(projects, cpuUsage),
|
||||||
|
[projects, cpuUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateProject = () => {
|
||||||
|
navigate("/projects/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to="/login"
|
||||||
|
replace
|
||||||
|
state={{ from: location.pathname ?? "/" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography level="h2" fontWeight={700}>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Realtime overview of project health and usage
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
{lastUpdated ? (
|
||||||
|
<Typography level="body-xs" textColor="neutral.500">
|
||||||
|
Updated {lastUpdated.toLocaleTimeString()}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<RefreshCw size={16} />}
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
onClick={fetchProjects}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ py: 6 }}>
|
||||||
|
<CircularProgress size="lg" />
|
||||||
|
<Typography level="body-sm" textColor="neutral.500" mt={1.5}>
|
||||||
|
Loading usage data…
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : error ? (
|
||||||
|
<Card variant="soft" color="danger">
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.5}>
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<Box>
|
||||||
|
<Typography level="title-sm">Unable to load dashboard</Typography>
|
||||||
|
<Typography level="body-sm">{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TopStatsGrid summary={summary} />
|
||||||
|
|
||||||
|
<Grid container spacing={2} mt={2}>
|
||||||
|
<Grid xs={12} lg={8}>
|
||||||
|
<ProjectsListCard
|
||||||
|
projects={projects}
|
||||||
|
onCreateProject={handleCreateProject}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12} lg={4}>
|
||||||
|
<ServerLoadCard summary={summary} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
aws_sigma_service/panel/src/pages/layout.tsx
Normal file
121
aws_sigma_service/panel/src/pages/layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { ChevronDown, CircleUserRound, LogOut, Server } from "lucide-react";
|
||||||
|
import { Outlet, useNavigate } from "react-router";
|
||||||
|
import { useAuth } from "../context/auth-context";
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, logout, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
py: 2,
|
||||||
|
px: 3,
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "neutral.outlinedBorder",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #5B8DEF 0%, #A855F7 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: "sm",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Server size={24} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
{Math.random() > 0.1 ? (
|
||||||
|
<>
|
||||||
|
<Typography level="title-md" fontWeight={600}>
|
||||||
|
AWS Sigma
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Our serverless functions are the most serverless in the world
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography level="title-md" fontWeight={600}>
|
||||||
|
AWS Ligma
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
What's ligma?
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Dropdown>
|
||||||
|
<MenuButton
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
pl: 1,
|
||||||
|
pr: 1.25,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: "xl",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "neutral.outlinedBorder",
|
||||||
|
boxShadow: "sm",
|
||||||
|
}}
|
||||||
|
endDecorator={<ChevronDown size={16} />}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: "left" }}>
|
||||||
|
<Typography level="body-sm" fontWeight={600}>
|
||||||
|
{user?.username}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<Menu placement="bottom-end">
|
||||||
|
<MenuItem color="danger" variant="solid" onClick={logout}>
|
||||||
|
<LogOut size={16} />
|
||||||
|
Logout
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Dropdown>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
startDecorator={<CircleUserRound size={18} />}
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
aws_sigma_service/panel/src/pages/login.tsx
Normal file
215
aws_sigma_service/panel/src/pages/login.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TabPanel,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Navigate, useLocation } from "react-router";
|
||||||
|
import { useAuth } from "../context/auth-context";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
type AuthMode = "login" | "register";
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { login, register, loading, isAuthenticated } = useAuth();
|
||||||
|
const [mode, setMode] = useState<AuthMode>("login");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [registerUsername, setRegisterUsername] = useState("");
|
||||||
|
const [registerPassword, setRegisterPassword] = useState("");
|
||||||
|
const [registerConfirm, setRegisterConfirm] = useState("");
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const [registerError, setRegisterError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoginSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoginError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ username, password });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||||
|
setLoginError("Invalid username or password");
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
setLoginError(err.message);
|
||||||
|
} else {
|
||||||
|
setLoginError("Login failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterSubmit = async (
|
||||||
|
event: React.FormEvent<HTMLFormElement>
|
||||||
|
) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setRegisterError(null);
|
||||||
|
|
||||||
|
if (registerPassword.length < 8) {
|
||||||
|
setRegisterError("Password must be at least 8 characters long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerPassword !== registerConfirm) {
|
||||||
|
setRegisterError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
username: registerUsername,
|
||||||
|
password: registerPassword,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.status === 409) {
|
||||||
|
setRegisterError("Username already exists");
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
setRegisterError(err.message);
|
||||||
|
} else {
|
||||||
|
setRegisterError("Registration failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "background.level1",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card sx={{ width: "100%", maxWidth: 420, p: 3, gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography level="title-lg" fontWeight={600}>
|
||||||
|
{mode === "login" ? "Welcome back" : "Create an account"}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
{mode === "login"
|
||||||
|
? "Sign in to manage your AWS Sigma functions"
|
||||||
|
: "Register a new operator account for AWS Sigma"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tabs
|
||||||
|
value={mode}
|
||||||
|
onChange={(_event, value) => setMode(value as AuthMode)}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<TabList
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab value="login">Sign in</Tab>
|
||||||
|
<Tab value="register">Register</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="login" sx={{ px: 0 }}>
|
||||||
|
<form onSubmit={handleLoginSubmit}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="ogurechek"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{loginError && (
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
{loginError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading && mode === "login"}
|
||||||
|
disabled={!username || !password}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="register" sx={{ px: 0 }}>
|
||||||
|
<form onSubmit={handleRegisterSubmit}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="ogurechek"
|
||||||
|
value={registerUsername}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRegisterUsername(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Minimum 8 characters"
|
||||||
|
value={registerPassword}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRegisterPassword(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Confirm password</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
value={registerConfirm}
|
||||||
|
onChange={(event) => setRegisterConfirm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{registerError && (
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
{registerError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading && mode === "register"}
|
||||||
|
disabled={
|
||||||
|
!registerUsername || !registerPassword || !registerConfirm
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
571
aws_sigma_service/panel/src/pages/projects/detail.tsx
Normal file
571
aws_sigma_service/panel/src/pages/projects/detail.tsx
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
type ColorPaletteProp,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Layers3,
|
||||||
|
PenSquare,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||||
|
import type { ProjectDto } from "@sigma/common";
|
||||||
|
import { apiClient } from "../../lib/api";
|
||||||
|
import { useAuth } from "../../context/auth-context";
|
||||||
|
import { formatNumber } from "../../components/dashboard/number-format";
|
||||||
|
|
||||||
|
type StatCard = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
color?: ColorPaletteProp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectDetailPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [deletingFunctionId, setDeletingFunctionId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [projectDeleteError, setProjectDeleteError] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [deletingProject, setDeletingProject] = useState(false);
|
||||||
|
|
||||||
|
const fetchProject = useCallback(async () => {
|
||||||
|
if (!projectId) {
|
||||||
|
setError("Missing project identifier");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<ProjectDto>(
|
||||||
|
`/projects/${projectId}`
|
||||||
|
);
|
||||||
|
setProject(data);
|
||||||
|
setDeleteError(null);
|
||||||
|
setProjectDeleteError(null);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
setProject(null);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Unable to load project details"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProject();
|
||||||
|
}, [fetchProject, isAuthenticated]);
|
||||||
|
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to="/login"
|
||||||
|
replace
|
||||||
|
state={{ from: location.pathname ?? "/" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 2) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cpuUsagePercent = useMemo(() => {
|
||||||
|
if (!project) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.cpuTimeQuotaMsPerMinute) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
(project.cpuTimeUsedMs / project.cpuTimeQuotaMsPerMinute) * 100
|
||||||
|
);
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
const stats = useMemo<StatCard[]>(() => {
|
||||||
|
if (!project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowStart = new Date(project.cpuTimeWindowStartedAt);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Functions",
|
||||||
|
value: formatNumber(project.functions.length),
|
||||||
|
description: "Deployed handlers in this project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Invocations",
|
||||||
|
value: formatNumber(project.functionsInvocationCount ?? 0),
|
||||||
|
description: "Aggregate executions across all functions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Errors",
|
||||||
|
value: formatNumber(project.functionsErrorCount ?? 0),
|
||||||
|
description: "Functions failed invocations count",
|
||||||
|
color: (project.functionsErrorCount ?? 0) > 0 ? "danger" : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "CPU quota (ms/min)",
|
||||||
|
value: formatNumber(project.cpuTimeQuotaMsPerMinute ?? 0),
|
||||||
|
description: "Configured execution budget",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "CPU used",
|
||||||
|
value: `${formatNumber(project.cpuTimeUsedMs ?? 0)} ms (${cpuUsagePercent.toFixed(1)}%)`,
|
||||||
|
description: "Usage during the current window",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Window started",
|
||||||
|
value: windowStart.toLocaleString(),
|
||||||
|
description: "Quota refresh timestamp",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [project, cpuUsagePercent]);
|
||||||
|
|
||||||
|
const sortedFunctions = useMemo(() => {
|
||||||
|
if (!project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...project.functions].sort(
|
||||||
|
(a, b) => (b.invocations ?? 0) - (a.invocations ?? 0)
|
||||||
|
);
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
const handleCreateFunction = () => {
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/projects/${projectId}/functions/new`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = useCallback(async () => {
|
||||||
|
if (!projectId || !project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? true
|
||||||
|
: window.confirm(
|
||||||
|
`Delete project "${project.name}" and all of its functions? This cannot be undone.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingProject(true);
|
||||||
|
setProjectDeleteError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/projects/${projectId}`);
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to delete project";
|
||||||
|
setProjectDeleteError(message);
|
||||||
|
} finally {
|
||||||
|
setDeletingProject(false);
|
||||||
|
}
|
||||||
|
}, [navigate, project, projectId]);
|
||||||
|
|
||||||
|
const handleDeleteFunction = useCallback(
|
||||||
|
async (functionId: string, functionName?: string) => {
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLabel = functionName ? `"${functionName}"` : "this function";
|
||||||
|
const confirmed =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? true
|
||||||
|
: window.confirm(
|
||||||
|
`Delete function ${targetLabel}? This cannot be undone.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteError(null);
|
||||||
|
setDeletingFunctionId(functionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/projects/${projectId}/functions/${functionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
setProject((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = prev.functions.find((f) => f._id === functionId);
|
||||||
|
if (!removed) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingFunctions = prev.functions.filter(
|
||||||
|
(f) => f._id !== functionId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
functions: remainingFunctions,
|
||||||
|
functionsInvocationCount: Math.max(
|
||||||
|
0,
|
||||||
|
(prev.functionsInvocationCount ?? 0) - (removed.invocations ?? 0)
|
||||||
|
),
|
||||||
|
functionsErrorCount: Math.max(
|
||||||
|
0,
|
||||||
|
(prev.functionsErrorCount ?? 0) - (removed.errors ?? 0)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to delete function";
|
||||||
|
setDeleteError(message);
|
||||||
|
} finally {
|
||||||
|
setDeletingFunctionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<ArrowLeft size={16} />}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Back to projects
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ py: 6 }}>
|
||||||
|
<CircularProgress size="lg" />
|
||||||
|
<Typography level="body-sm" textColor="neutral.500" mt={1.5}>
|
||||||
|
Loading project insights…
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : error ? (
|
||||||
|
<Card variant="soft" color="danger">
|
||||||
|
<Stack direction="row" gap={1.5} alignItems="center">
|
||||||
|
<Alert color="danger" variant="plain">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="soft"
|
||||||
|
startDecorator={<RefreshCw size={16} />}
|
||||||
|
onClick={fetchProject}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : project ? (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: "flex-start", md: "center" }}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Stack gap={1}>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<Typography level="h2" fontWeight={700}>
|
||||||
|
{project.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="sm" variant="soft" color="neutral">
|
||||||
|
{project.slug}
|
||||||
|
</Chip>
|
||||||
|
</Stack>
|
||||||
|
{project.description ? (
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
{project.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
{lastUpdated ? (
|
||||||
|
<Typography
|
||||||
|
level="body-xs"
|
||||||
|
textColor="neutral.500"
|
||||||
|
sx={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
Updated {lastUpdated.toLocaleTimeString()}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
startDecorator={<RefreshCw size={16} />}
|
||||||
|
onClick={fetchProject}
|
||||||
|
disabled={loading || deletingProject}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="solid"
|
||||||
|
color="danger"
|
||||||
|
startDecorator={<Trash2 size={16} />}
|
||||||
|
onClick={handleDeleteProject}
|
||||||
|
loading={deletingProject}
|
||||||
|
disabled={deletingProject}
|
||||||
|
>
|
||||||
|
Delete project
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{projectDeleteError ? (
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
{projectDeleteError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Grid container spacing={2} mb={2} sx={{ mt: 1 }}>
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<Grid key={stat.label} xs={12} sm={6} lg={4}>
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ p: 2.25, gap: 0.5 }}
|
||||||
|
color={stat.color}
|
||||||
|
>
|
||||||
|
<Typography level="body-xs" textColor="neutral.500">
|
||||||
|
{stat.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="title-md" fontWeight={600}>
|
||||||
|
{stat.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-xs" textColor="neutral.500">
|
||||||
|
{stat.description}
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Card sx={{ p: 0 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{
|
||||||
|
px: 2.5,
|
||||||
|
py: 1.75,
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.25}>
|
||||||
|
<Layers3 size={18} />
|
||||||
|
<Stack gap={0.25}>
|
||||||
|
<Typography level="title-sm" fontWeight={600}>
|
||||||
|
Functions
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-xs" textColor="neutral.500">
|
||||||
|
Invocation and error metrics per endpoint
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
<Chip size="sm" variant="soft">
|
||||||
|
{project.functions.length} total
|
||||||
|
</Chip>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<Plus size={16} />}
|
||||||
|
onClick={handleCreateFunction}
|
||||||
|
>
|
||||||
|
Create function
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{deleteError ? (
|
||||||
|
<Alert color="danger" variant="soft" sx={{ mx: 2.5, my: 1.5 }}>
|
||||||
|
{deleteError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sortedFunctions.length ? (
|
||||||
|
<Table
|
||||||
|
size="sm"
|
||||||
|
stickyHeader
|
||||||
|
sx={{
|
||||||
|
"--TableCell-headBackground": "transparent",
|
||||||
|
"--TableCell-paddingX": "20px",
|
||||||
|
"--TableCell-paddingY": "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: "16%" }}>Function</th>
|
||||||
|
<th style={{ width: "16%" }}>Methods</th>
|
||||||
|
<th style={{ width: "16%" }}>Invocations</th>
|
||||||
|
<th style={{ width: "16%" }}>Errors</th>
|
||||||
|
<th style={{ width: "16%" }}>Last invocation</th>
|
||||||
|
<th style={{ width: "16%" }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedFunctions.map((func) => {
|
||||||
|
const methods: string[] = func.methods?.length
|
||||||
|
? func.methods
|
||||||
|
: ["ANY"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={func._id}>
|
||||||
|
<td>
|
||||||
|
<Stack gap={0.25}>
|
||||||
|
<Typography level="body-sm" fontWeight={600}>
|
||||||
|
{func.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-xs" textColor="neutral.500">
|
||||||
|
{func.path}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Stack direction="row" gap={0.5} flexWrap="wrap">
|
||||||
|
{methods.map((method) => (
|
||||||
|
<Chip key={method} size="sm" variant="outlined">
|
||||||
|
{method}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography level="body-sm" fontWeight={500}>
|
||||||
|
{formatNumber(func.invocations ?? 0)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography
|
||||||
|
level="body-sm"
|
||||||
|
fontWeight={500}
|
||||||
|
color={func.errors ? "danger" : undefined}
|
||||||
|
>
|
||||||
|
{formatNumber(func.errors ?? 0)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Typography
|
||||||
|
level="body-xs"
|
||||||
|
textColor={
|
||||||
|
func.lastInvocation ? undefined : "neutral.500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{func.lastInvocation?.toLocaleString() ?? "Never"}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={1}
|
||||||
|
justifyContent="flex-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
startDecorator={<PenSquare size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/projects/${projectId}/functions/${func._id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
color="danger"
|
||||||
|
startDecorator={<Trash2 size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteFunction(func._id, func.name)
|
||||||
|
}
|
||||||
|
loading={deletingFunctionId === func._id}
|
||||||
|
disabled={deletingFunctionId === func._id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ py: 5 }}>
|
||||||
|
<Typography level="title-sm">No functions deployed</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Once you add functions, their metrics will show up here.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Card variant="soft">
|
||||||
|
<Typography level="title-sm">
|
||||||
|
We couldn’t find that project. Double-check the URL and try again.
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
493
aws_sigma_service/panel/src/pages/projects/functions/detail.tsx
Normal file
493
aws_sigma_service/panel/src/pages/projects/functions/detail.tsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Link,
|
||||||
|
Option,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||||
|
import { ArrowLeft, RefreshCw, Save } from "lucide-react";
|
||||||
|
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||||
|
import type { FunctionDto, ProjectDto, UpdateFunctionDto } from "@sigma/common";
|
||||||
|
import { apiClient } from "../../../lib/api";
|
||||||
|
import { useAuth } from "../../../context/auth-context";
|
||||||
|
import { configureEditor } from "../../../lib/editor";
|
||||||
|
import { BACKEND_BASE } from "../../../config";
|
||||||
|
|
||||||
|
const PATH_REGEX =
|
||||||
|
/^\/(?:$|(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+)(?:\/(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+))*)$/;
|
||||||
|
|
||||||
|
const HTTP_METHODS = [
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS",
|
||||||
|
"HEAD",
|
||||||
|
];
|
||||||
|
|
||||||
|
function sanitizeMethods(values?: string[] | null): string[] {
|
||||||
|
if (!values?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(values.map((method) => method.toUpperCase())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value: string): string {
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
return `/${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 1 && value.endsWith("/")) {
|
||||||
|
return value.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFunctionPayload = UpdateFunctionDto & {
|
||||||
|
methods?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FunctionDetailPage() {
|
||||||
|
const monaco = useMonaco();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { projectId, functionId } = useParams<{
|
||||||
|
projectId: string;
|
||||||
|
functionId: string;
|
||||||
|
}>();
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [func, setFunc] = useState<FunctionDto | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [path, setPath] = useState("/");
|
||||||
|
const [methods, setMethods] = useState<string[]>([]);
|
||||||
|
const [code, setCode] = useState<string>(
|
||||||
|
"// Write your function code here\n"
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedPath = useMemo(
|
||||||
|
() => normalizePath(path.trim() || "/"),
|
||||||
|
[path]
|
||||||
|
);
|
||||||
|
const pathIsValid = PATH_REGEX.test(normalizedPath);
|
||||||
|
const canSave = Boolean(name.trim()) && pathIsValid && !saving;
|
||||||
|
const functionUrl = useMemo(() => {
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedBase = BACKEND_BASE.replace(/\/+$/, "");
|
||||||
|
const execBase = `${trimmedBase ? trimmedBase : ""}/api/v1/exec/${project.slug}`;
|
||||||
|
const invocationPath = normalizedPath === "/" ? "" : normalizedPath;
|
||||||
|
|
||||||
|
return `${execBase}${invocationPath}`;
|
||||||
|
}, [project, normalizedPath]);
|
||||||
|
|
||||||
|
const logs = useMemo(() => func?.logs ?? [], [func]);
|
||||||
|
const hasLogs = logs.length > 0;
|
||||||
|
const logsCountLabel = hasLogs
|
||||||
|
? `${logs.length} entr${logs.length === 1 ? "y" : "ies"}`
|
||||||
|
: "No logs yet";
|
||||||
|
const logsValue = useMemo(
|
||||||
|
() => (hasLogs ? [...logs].reverse().join("\n") : ""),
|
||||||
|
[hasLogs, logs]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (monaco) {
|
||||||
|
configureEditor(monaco);
|
||||||
|
}
|
||||||
|
}, [monaco]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 2) {
|
||||||
|
navigate(-1);
|
||||||
|
} else if (projectId) {
|
||||||
|
navigate(`/projects/${projectId}`);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFunction = useCallback(async () => {
|
||||||
|
if (!projectId || !functionId) {
|
||||||
|
setError("Missing identifiers for project or function");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<ProjectDto>(
|
||||||
|
`/projects/${projectId}`
|
||||||
|
);
|
||||||
|
const matchingFunction = data.functions.find(
|
||||||
|
(item) => item._id === functionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingFunction) {
|
||||||
|
setError("We couldn't find that function in this project.");
|
||||||
|
setProject(data);
|
||||||
|
setFunc(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProject(data);
|
||||||
|
setFunc(matchingFunction);
|
||||||
|
setName(matchingFunction.name ?? "");
|
||||||
|
setPath(matchingFunction.path ?? "/");
|
||||||
|
setMethods(sanitizeMethods(matchingFunction.methods));
|
||||||
|
setCode(matchingFunction.code ?? "");
|
||||||
|
setSuccess(null);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to load function details. Please try again.";
|
||||||
|
setError(message);
|
||||||
|
setFunc(null);
|
||||||
|
setProject(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, functionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFunction();
|
||||||
|
}, [isAuthenticated, loadFunction]);
|
||||||
|
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to="/login"
|
||||||
|
replace
|
||||||
|
state={{ from: location.pathname ?? "/" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectId || !functionId) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
Missing project or function identifier. Please return to the dashboard
|
||||||
|
and try again.
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMethodsChange = (_: unknown, newValue: string[] | null) => {
|
||||||
|
setMethods(newValue?.map((method) => method.toUpperCase()) ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!canSave || !projectId || !functionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload: UpdateFunctionPayload = {
|
||||||
|
name: name.trim(),
|
||||||
|
path: normalizedPath,
|
||||||
|
code: code ?? "",
|
||||||
|
methods: methods.length ? methods : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.put<FunctionDto>(
|
||||||
|
`/projects/${projectId}/functions/${functionId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
setFunc(data);
|
||||||
|
setName(data.name);
|
||||||
|
setPath(data.path);
|
||||||
|
setMethods(sanitizeMethods(data.methods));
|
||||||
|
setCode(data.code ?? "");
|
||||||
|
setSuccess("Function updated successfully.");
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to save function.";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 960,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<ArrowLeft size={16} />}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Back to project
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: "flex-start", md: "center" }}
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={1.5}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography level="h2" fontWeight={700}>
|
||||||
|
{func?.name ?? "Function"}
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
{project
|
||||||
|
? `${project.name} · ${func?.path ?? ""}`
|
||||||
|
: "Loading project..."}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<RefreshCw size={16} />}
|
||||||
|
onClick={loadFunction}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<Alert color="success" variant="soft">
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Loading function details...
|
||||||
|
</Typography>
|
||||||
|
) : func ? (
|
||||||
|
<Stack component="form" gap={2.5} onSubmit={handleSubmit}>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="Webhook handler"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl required error={!pathIsValid}>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="/webhooks/payments/:id"
|
||||||
|
value={path}
|
||||||
|
onChange={(event) => setPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Must start with /. Use :param for dynamic segments.
|
||||||
|
</FormHelperText>
|
||||||
|
{project && pathIsValid && functionUrl ? (
|
||||||
|
<FormHelperText>
|
||||||
|
Available at
|
||||||
|
<Link
|
||||||
|
href={functionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{functionUrl}
|
||||||
|
</Link>
|
||||||
|
</FormHelperText>
|
||||||
|
) : null}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Allowed methods</FormLabel>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
placeholder="Any method"
|
||||||
|
value={methods}
|
||||||
|
onChange={handleMethodsChange}
|
||||||
|
>
|
||||||
|
{HTTP_METHODS.map((method) => (
|
||||||
|
<Option key={method} value={method}>
|
||||||
|
{method}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{methods.length ? (
|
||||||
|
<Stack direction="row" gap={0.5} flexWrap="wrap" mt={1}>
|
||||||
|
{methods.map((method) => (
|
||||||
|
<Chip key={method} size="sm" variant="soft">
|
||||||
|
{method}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
<FormHelperText>
|
||||||
|
Leave empty to accept any HTTP method.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Code</FormLabel>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: "md",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
height="420px"
|
||||||
|
defaultLanguage="javascript"
|
||||||
|
theme="vs-light"
|
||||||
|
value={code}
|
||||||
|
onChange={(value: string | undefined) =>
|
||||||
|
setCode(value ?? "")
|
||||||
|
}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
tabSize: 2,
|
||||||
|
fontSize: 14,
|
||||||
|
automaticLayout: true,
|
||||||
|
fixedOverflowWidgets: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<FormHelperText>
|
||||||
|
<Typography fontSize="inherit">
|
||||||
|
Write your function code in plain JavaScript.
|
||||||
|
<br />
|
||||||
|
Use{" "}
|
||||||
|
<Typography variant="soft" fontSize="inherit">
|
||||||
|
req
|
||||||
|
</Typography>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Typography variant="soft" fontSize="inherit">
|
||||||
|
res
|
||||||
|
</Typography>{" "}
|
||||||
|
objects to handle requests and responses.
|
||||||
|
<br />
|
||||||
|
You can use{" "}
|
||||||
|
<Typography variant="soft" fontSize="inherit">
|
||||||
|
{`scope_${project?.slug}`}
|
||||||
|
</Typography>{" "}
|
||||||
|
table within your SQL queries. Just be sure to create it
|
||||||
|
first.
|
||||||
|
</Typography>
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
mb={0.5}
|
||||||
|
>
|
||||||
|
<FormLabel sx={{ mb: 0 }}>Recent logs</FormLabel>
|
||||||
|
<Chip size="sm" variant="soft" color="neutral">
|
||||||
|
{logsCountLabel}
|
||||||
|
</Chip>
|
||||||
|
</Stack>
|
||||||
|
<Textarea
|
||||||
|
minRows={6}
|
||||||
|
maxRows={12}
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
value={logsValue}
|
||||||
|
readOnly
|
||||||
|
placeholder="Logs will appear here after the function runs."
|
||||||
|
sx={{
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
|
||||||
|
bgcolor: "background.level1",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
{hasLogs
|
||||||
|
? "Newest entries are shown first. Use Refresh to pull the latest logs."
|
||||||
|
: "Invoke this function to generate logs, then use Refresh to load them."}
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Stack direction="row" gap={1.5} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
startDecorator={<Save size={18} />}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!canSave}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Select a function from the project to begin editing.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
aws_sigma_service/panel/src/pages/projects/functions/new.tsx
Normal file
204
aws_sigma_service/panel/src/pages/projects/functions/new.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { ArrowLeft, Code2 } from "lucide-react";
|
||||||
|
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||||
|
import type { CreateFunctionDto } from "@sigma/common";
|
||||||
|
import { useAuth } from "../../../context/auth-context";
|
||||||
|
import { apiClient } from "../../../lib/api";
|
||||||
|
|
||||||
|
const PATH_REGEX =
|
||||||
|
/^\/(?:$|(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+)(?:\/(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+))*)$/;
|
||||||
|
|
||||||
|
function normalizePath(value: string): string {
|
||||||
|
if (!value.startsWith("/")) {
|
||||||
|
return `/${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 1 && value.endsWith("/")) {
|
||||||
|
return value.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateFunctionPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [path, setPath] = useState("/");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to="/login"
|
||||||
|
replace
|
||||||
|
state={{ from: location.pathname ?? "/" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
Missing project identifier. Please return to the dashboard and try
|
||||||
|
again.
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = normalizePath(path.trim() || "/");
|
||||||
|
const pathIsValid = PATH_REGEX.test(normalizedPath);
|
||||||
|
const canSubmit = Boolean(name.trim()) && pathIsValid && !submitting;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 2) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate(`/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload: CreateFunctionDto = {
|
||||||
|
name: name.trim(),
|
||||||
|
path: normalizedPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/projects/${projectId}/functions`, payload);
|
||||||
|
navigate(`/projects/${projectId}`, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
if (err.response?.data?.message) {
|
||||||
|
const message = Array.isArray(err.response.data.message)
|
||||||
|
? err.response.data.message.join("; ")
|
||||||
|
: err.response.data.message;
|
||||||
|
setError(message ?? "Failed to create function");
|
||||||
|
} else {
|
||||||
|
setError(err.message || "Failed to create function");
|
||||||
|
}
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError("Failed to create function");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 640,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<ArrowLeft size={16} />}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Back to project
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography level="h2" fontWeight={700}>
|
||||||
|
New function
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Deploy a new handler for this project.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<Stack component="form" gap={2.5} onSubmit={handleSubmit}>
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="Webhook handler"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Used in the dashboard and logs to identify this function.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl required error={!pathIsValid}>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="/webhooks/payments/:id"
|
||||||
|
value={path}
|
||||||
|
onChange={(event) => setPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Must start with /. Use :param for dynamic segments.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction="row" gap={1.5} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
startDecorator={<Code2 size={18} />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
Create function
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
aws_sigma_service/panel/src/pages/projects/new.tsx
Normal file
240
aws_sigma_service/panel/src/pages/projects/new.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { ArrowLeft, Rocket } from "lucide-react";
|
||||||
|
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||||
|
import type { CreateProjectDto } from "@sigma/common";
|
||||||
|
import { useAuth } from "../../context/auth-context";
|
||||||
|
import { apiClient } from "../../lib/api";
|
||||||
|
|
||||||
|
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
const DEFAULT_CPU_QUOTA = 1000;
|
||||||
|
|
||||||
|
function slugify(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateProjectPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [cpuQuota, setCpuQuota] = useState<string>("");
|
||||||
|
const [slugEdited, setSlugEdited] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to="/login"
|
||||||
|
replace
|
||||||
|
state={{ from: location.pathname ?? "/projects/new" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugIsValid = !slug || SLUG_REGEX.test(slug);
|
||||||
|
const quotaNumber = cpuQuota ? Number(cpuQuota) : undefined;
|
||||||
|
const quotaIsValid =
|
||||||
|
quotaNumber === undefined ||
|
||||||
|
(Number.isFinite(quotaNumber) && quotaNumber >= 1);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
Boolean(name.trim()) &&
|
||||||
|
Boolean(slug.trim()) &&
|
||||||
|
Boolean(description.trim()) &&
|
||||||
|
slugIsValid &&
|
||||||
|
quotaIsValid &&
|
||||||
|
!submitting;
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setName(value);
|
||||||
|
if (!slugEdited) {
|
||||||
|
setSlug(slugify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlugChange = (value: string) => {
|
||||||
|
setSlugEdited(true);
|
||||||
|
setSlug(slugify(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload: CreateProjectDto = {
|
||||||
|
name: name.trim(),
|
||||||
|
slug: slug.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (quotaNumber !== undefined) {
|
||||||
|
payload.cpuTimeQuotaMsPerMinute = quotaNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post("/projects", payload);
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
if (err.response?.status === 409) {
|
||||||
|
setError("A project with this slug already exists.");
|
||||||
|
} else if (err.response?.data?.message) {
|
||||||
|
const message = Array.isArray(err.response.data.message)
|
||||||
|
? err.response.data.message.join("; ")
|
||||||
|
: err.response.data.message;
|
||||||
|
setError(message ?? "Failed to create project");
|
||||||
|
} else {
|
||||||
|
setError(err.message || "Failed to create project");
|
||||||
|
}
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError("Failed to create project");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (window.history.length > 2) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
startDecorator={<ArrowLeft size={16} />}
|
||||||
|
onClick={handleBack}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography level="h2" fontWeight={700}>
|
||||||
|
New project
|
||||||
|
</Typography>
|
||||||
|
<Typography level="body-sm" textColor="neutral.500">
|
||||||
|
Define the essentials for a new compute project. You can add
|
||||||
|
functions right after this step.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<Stack component="form" gap={2.5} onSubmit={handleSubmit}>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} gap={2}>
|
||||||
|
<FormControl required sx={{ flex: 1 }}>
|
||||||
|
<FormLabel>Project name</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="Payments service"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => handleNameChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>Shown across the dashboard.</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl
|
||||||
|
required
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
error={Boolean(slug) && !slugIsValid}
|
||||||
|
>
|
||||||
|
<FormLabel>Slug</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="payments-service"
|
||||||
|
value={slug}
|
||||||
|
onChange={(event) => handleSlugChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Lowercase letters, numbers, and dashes only ({"/projects/"}
|
||||||
|
{slug || "slug"}).
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
minRows={3}
|
||||||
|
placeholder="Briefly describe what this project powers"
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Helps teammates understand the workload.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert color="danger" variant="soft">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction="row" gap={1.5} justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="neutral"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
startDecorator={<Rocket size={18} />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
Create project
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
aws_sigma_service/panel/src/parcel.d.ts
vendored
Normal file
4
aws_sigma_service/panel/src/parcel.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "bundle-text:*" {
|
||||||
|
const contents: string;
|
||||||
|
export default contents;
|
||||||
|
}
|
||||||
29
aws_sigma_service/panel/tsconfig.json
Normal file
29
aws_sigma_service/panel/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
45
aws_sigma_service/tsconfig.json
Normal file
45
aws_sigma_service/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
// "rootDir": "./src",
|
||||||
|
// "outDir": "./dist",
|
||||||
|
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "es2024",
|
||||||
|
"types": [],
|
||||||
|
// For nodejs:
|
||||||
|
// "lib": ["esnext"],
|
||||||
|
// "types": ["node"],
|
||||||
|
// and npm install -D @types/node
|
||||||
|
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
|
||||||
|
// Style Options
|
||||||
|
// "noImplicitReturns": true,
|
||||||
|
// "noImplicitOverride": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
// "noFallthroughCasesInSwitch": true,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true,
|
||||||
|
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
6932
aws_sigma_service/yarn.lock
Normal file
6932
aws_sigma_service/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
7
grob/.bash_logout
Normal file
7
grob/.bash_logout
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# ~/.bash_logout: executed by bash(1) when login shell exits.
|
||||||
|
|
||||||
|
# when leaving the console clear the screen to increase privacy
|
||||||
|
|
||||||
|
if [ "$SHLVL" = 1 ]; then
|
||||||
|
[ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q
|
||||||
|
fi
|
||||||
117
grob/.bashrc
Normal file
117
grob/.bashrc
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# ~/.bashrc: executed by bash(1) for non-login shells.
|
||||||
|
# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
|
||||||
|
# for examples
|
||||||
|
|
||||||
|
# If not running interactively, don't do anything
|
||||||
|
case $- in
|
||||||
|
*i*) ;;
|
||||||
|
*) return;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# don't put duplicate lines or lines starting with space in the history.
|
||||||
|
# See bash(1) for more options
|
||||||
|
HISTCONTROL=ignoreboth
|
||||||
|
|
||||||
|
# append to the history file, don't overwrite it
|
||||||
|
shopt -s histappend
|
||||||
|
|
||||||
|
# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)
|
||||||
|
HISTSIZE=1000
|
||||||
|
HISTFILESIZE=2000
|
||||||
|
|
||||||
|
# check the window size after each command and, if necessary,
|
||||||
|
# update the values of LINES and COLUMNS.
|
||||||
|
shopt -s checkwinsize
|
||||||
|
|
||||||
|
# If set, the pattern "**" used in a pathname expansion context will
|
||||||
|
# match all files and zero or more directories and subdirectories.
|
||||||
|
#shopt -s globstar
|
||||||
|
|
||||||
|
# make less more friendly for non-text input files, see lesspipe(1)
|
||||||
|
[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
|
||||||
|
|
||||||
|
# set variable identifying the chroot you work in (used in the prompt below)
|
||||||
|
if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then
|
||||||
|
debian_chroot=$(cat /etc/debian_chroot)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set a fancy prompt (non-color, unless we know we "want" color)
|
||||||
|
case "$TERM" in
|
||||||
|
xterm-color|*-256color) color_prompt=yes;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# uncomment for a colored prompt, if the terminal has the capability; turned
|
||||||
|
# off by default to not distract the user: the focus in a terminal window
|
||||||
|
# should be on the output of commands, not on the prompt
|
||||||
|
#force_color_prompt=yes
|
||||||
|
|
||||||
|
if [ -n "$force_color_prompt" ]; then
|
||||||
|
if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then
|
||||||
|
# We have color support; assume it's compliant with Ecma-48
|
||||||
|
# (ISO/IEC-6429). (Lack of such support is extremely rare, and such
|
||||||
|
# a case would tend to support setf rather than setaf.)
|
||||||
|
color_prompt=yes
|
||||||
|
else
|
||||||
|
color_prompt=
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$color_prompt" = yes ]; then
|
||||||
|
PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
|
||||||
|
else
|
||||||
|
PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '
|
||||||
|
fi
|
||||||
|
unset color_prompt force_color_prompt
|
||||||
|
|
||||||
|
# If this is an xterm set the title to user@host:dir
|
||||||
|
case "$TERM" in
|
||||||
|
xterm*|rxvt*)
|
||||||
|
PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# enable color support of ls and also add handy aliases
|
||||||
|
if [ -x /usr/bin/dircolors ]; then
|
||||||
|
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
|
||||||
|
alias ls='ls --color=auto'
|
||||||
|
#alias dir='dir --color=auto'
|
||||||
|
#alias vdir='vdir --color=auto'
|
||||||
|
|
||||||
|
alias grep='grep --color=auto'
|
||||||
|
alias fgrep='fgrep --color=auto'
|
||||||
|
alias egrep='egrep --color=auto'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# colored GCC warnings and errors
|
||||||
|
#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
|
||||||
|
|
||||||
|
# some more ls aliases
|
||||||
|
alias ll='ls -alF'
|
||||||
|
alias la='ls -A'
|
||||||
|
alias l='ls -CF'
|
||||||
|
|
||||||
|
# Add an "alert" alias for long running commands. Use like so:
|
||||||
|
# sleep 10; alert
|
||||||
|
alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"'
|
||||||
|
|
||||||
|
# Alias definitions.
|
||||||
|
# You may want to put all your additions into a separate file like
|
||||||
|
# ~/.bash_aliases, instead of adding them here directly.
|
||||||
|
# See /usr/share/doc/bash-doc/examples in the bash-doc package.
|
||||||
|
|
||||||
|
if [ -f ~/.bash_aliases ]; then
|
||||||
|
. ~/.bash_aliases
|
||||||
|
fi
|
||||||
|
|
||||||
|
# enable programmable completion features (you don't need to enable
|
||||||
|
# this, if it's already enabled in /etc/bash.bashrc and /etc/profile
|
||||||
|
# sources /etc/bash.bashrc).
|
||||||
|
if ! shopt -oq posix; then
|
||||||
|
if [ -f /usr/share/bash-completion/bash_completion ]; then
|
||||||
|
. /usr/share/bash-completion/bash_completion
|
||||||
|
elif [ -f /etc/bash_completion ]; then
|
||||||
|
. /etc/bash_completion
|
||||||
|
fi
|
||||||
|
fi
|
||||||
27
grob/.profile
Normal file
27
grob/.profile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# ~/.profile: executed by the command interpreter for login shells.
|
||||||
|
# This file is not read by bash(1), if ~/.bash_profile or ~/.bash_login
|
||||||
|
# exists.
|
||||||
|
# see /usr/share/doc/bash/examples/startup-files for examples.
|
||||||
|
# the files are located in the bash-doc package.
|
||||||
|
|
||||||
|
# the default umask is set in /etc/profile; for setting the umask
|
||||||
|
# for ssh logins, install and configure the libpam-umask package.
|
||||||
|
#umask 022
|
||||||
|
|
||||||
|
# if running bash
|
||||||
|
if [ -n "$BASH_VERSION" ]; then
|
||||||
|
# include .bashrc if it exists
|
||||||
|
if [ -f "$HOME/.bashrc" ]; then
|
||||||
|
. "$HOME/.bashrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set PATH so it includes user's private bin if it exists
|
||||||
|
if [ -d "$HOME/bin" ] ; then
|
||||||
|
PATH="$HOME/bin:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set PATH so it includes user's private bin if it exists
|
||||||
|
if [ -d "$HOME/.local/bin" ] ; then
|
||||||
|
PATH="$HOME/.local/bin:$PATH"
|
||||||
|
fi
|
||||||
14
grob/Dockerfile
Normal file
14
grob/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM ubuntu@sha256:c35e29c9450151419d9448b0fd75374fec4fff364a27f176fb458d472dfc9e54
|
||||||
|
|
||||||
|
RUN useradd -m -u 1001 ctf
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY grob .
|
||||||
|
RUN chmod +x /app/grob
|
||||||
|
|
||||||
|
RUN mkdir -p /app/history && chown -R ctf:ctf /app
|
||||||
|
|
||||||
|
CMD ["/docker-entrypoint.sh"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user