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