first commit

This commit is contained in:
root
2025-12-05 07:14:11 +00:00
commit 2ed4393eb9
129 changed files with 20524 additions and 0 deletions

View 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
View 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

View File

@@ -0,0 +1,8 @@
**/dist/
**/node_modules/
**/target/
**/.parcel-cache/
**/__pycache__/
**/docker-compose*
**/Dockerfile*
**/.git/

5
aws_sigma_service/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
dist/
node_modules/
target/
.parcel-cache/
__pycache__/

View 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

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs

View 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"]

View 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

View 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": []
}
}

View 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"
}
}

View 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 {}

View File

@@ -0,0 +1 @@
export const JWT_SECRET = process.env["JWT_SECRET"] ?? "supersecret";

View File

@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

View 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 };
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}

View 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;
}
}

View 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);
}
}

View File

@@ -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;
}
}

View 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() };
}
}

View 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);
}
}

View 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,
};
}
}

View 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);
}
}

View 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);
}
}
}

View 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;
}
}
}

View 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 {}

View 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;
}
}

View 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();

View 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);

View 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);

View 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);

View 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),
};
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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,
};
}
}

View 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 });
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}

View 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"
}
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsNumber } from "class-validator";
export class CpuInfoDto {
@IsNumber()
@IsNotEmpty()
usage!: number;
}

View 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[];
}

View 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;
}

View 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";

View 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;
}

View 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[];
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/mapped-types";
import { ProjectDto } from "./project.dto.js";
export class QueryProjectDto extends PartialType(ProjectDto) {}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, MinLength } from "class-validator";
export class RegisterUserDto {
@IsNotEmpty()
@IsString()
username!: string;
@MinLength(8)
@IsString()
password!: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateFunctionDto } from "./create-function.dto.js";
export class UpdateFunctionDto extends PartialType(CreateFunctionDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateProjectDto } from "./create-project.dto.js";
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

View 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"]) {}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from "class-validator";
export class UserDto {
@IsNotEmpty()
@IsString()
username!: string;
}

View File

@@ -0,0 +1 @@
export * from "./dto/dto.js";

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}

View 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:

View 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:

View File

@@ -0,0 +1,3 @@
{
"editor.tabSize": 4
}

3814
aws_sigma_service/executor/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View 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"] }

View 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 }>;
}

View 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())
}
}

View File

@@ -0,0 +1,2 @@
pub(crate) mod fs;
pub(crate) mod sql;

View 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();
})();

View 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")
}
}

View 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(())
}
}

View 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")
}
}

View 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
}
}
}

View 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;
#}
}

View 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"
}

View File

@@ -0,0 +1,10 @@
{
"extends": [
"@parcel/config-default"
],
"transformers": {
"bundle-text:*": [
"@parcel/transformer-inline-string"
]
}
}

View 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"
}
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
const numberFormatter = new Intl.NumberFormat("en-US");
export function formatNumber(value: number) {
return numberFormatter.format(value);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,9 @@
export type DashboardSummary = {
totalProjects: number;
totalFunctions: number;
totalInvocations: number;
totalErrors: number;
quotaUsed: number;
quotaTotal: number;
serverLoadPercent: number;
};

View File

@@ -0,0 +1 @@
export const BACKEND_BASE = process.env["BACKEND_BASE"] || "/";

View 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;
}

View 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>

View 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 />);

View 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;
}

View 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");
}
}

View 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>
);
}

View 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 />
</>
);
}

View 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>
);
}

View 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 couldnt find that project. Double-check the URL and try again.
</Typography>
</Card>
)}
</Box>
);
}

View 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&nbsp;
<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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
declare module "bundle-text:*" {
const contents: string;
export default contents;
}

View 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
}
}

View 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

File diff suppressed because it is too large Load Diff

7
grob/.bash_logout Normal file
View 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
View 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
View 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
View 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