first commit
This commit is contained in:
10
aws_sigma_service/panel/.parcelrc
Normal file
10
aws_sigma_service/panel/.parcelrc
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": [
|
||||
"@parcel/config-default"
|
||||
],
|
||||
"transformers": {
|
||||
"bundle-text:*": [
|
||||
"@parcel/transformer-inline-string"
|
||||
]
|
||||
}
|
||||
}
|
||||
31
aws_sigma_service/panel/package.json
Normal file
31
aws_sigma_service/panel/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@sigma/panel",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"source": "src/index.html",
|
||||
"scripts": {
|
||||
"dev": "parcel",
|
||||
"build": "parcel build",
|
||||
"format": "prettier -w src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/joy": "5.0.0-beta.52",
|
||||
"@sigma/common": "workspace:^",
|
||||
"axios": "^1.13.2",
|
||||
"lucide-react": "^0.554.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"parcel": "^2.14.0",
|
||||
"prettier": "^3.6.2"
|
||||
}
|
||||
}
|
||||
36
aws_sigma_service/panel/src/app.tsx
Normal file
36
aws_sigma_service/panel/src/app.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { Layout } from "./pages/layout";
|
||||
import { Login } from "./pages/login";
|
||||
import { Dashboard } from "./pages/dashboard";
|
||||
import { CreateProjectPage } from "./pages/projects/new";
|
||||
import { ProjectDetailPage } from "./pages/projects/detail";
|
||||
import { CreateFunctionPage } from "./pages/projects/functions/new";
|
||||
import { FunctionDetailPage } from "./pages/projects/functions/detail";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="projects">
|
||||
<Route path="new" element={<CreateProjectPage />} />
|
||||
<Route path=":projectId" element={<ProjectDetailPage />} />
|
||||
<Route
|
||||
path=":projectId/functions/new"
|
||||
element={<CreateFunctionPage />}
|
||||
/>
|
||||
<Route
|
||||
path=":projectId/functions/:functionId"
|
||||
element={<FunctionDetailPage />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="login" element={<Login />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Box, Button, Card, Chip, Stack, Table, Typography } from "@mui/joy";
|
||||
import { Layers3, PenSquare, Plus } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { formatNumber } from "./number-format";
|
||||
import type { ProjectSummary } from "./types";
|
||||
|
||||
export type ProjectsListCardProps = {
|
||||
projects: ProjectSummary[];
|
||||
onCreateProject: () => void;
|
||||
};
|
||||
|
||||
export function ProjectsListCard({
|
||||
projects,
|
||||
onCreateProject,
|
||||
}: ProjectsListCardProps) {
|
||||
const hasProjects = projects.length > 0;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleOpenProject = (projectId: string) => {
|
||||
navigate(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
const handleEditProject = (projectId: string) => {
|
||||
navigate(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%", p: 0 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={1.25}>
|
||||
<Layers3 size={18} />
|
||||
<Box>
|
||||
<Typography level="title-sm" fontWeight={600}>
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography level="body-xs" textColor="neutral.500">
|
||||
Overview of your deployed workloads
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Chip size="sm" variant="soft">
|
||||
{projects.length} total
|
||||
</Chip>
|
||||
<Button
|
||||
size="sm"
|
||||
startDecorator={<Plus size={16} />}
|
||||
onClick={onCreateProject}
|
||||
>
|
||||
New project
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{hasProjects ? (
|
||||
<Table
|
||||
size="sm"
|
||||
stickyHeader
|
||||
sx={{
|
||||
"--TableCell-headBackground": "transparent",
|
||||
"--TableCell-paddingX": "16px",
|
||||
"--TableCell-paddingY": "12px",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "35%" }}>Name</th>
|
||||
<th>Functions</th>
|
||||
<th>Invocations</th>
|
||||
<th>Errors</th>
|
||||
<th>CPU usage</th>
|
||||
<th style={{ width: "12%" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => {
|
||||
const functionsCount = project.functions?.length ?? 0;
|
||||
const invocations = project.functionsInvocationCount ?? 0;
|
||||
const errors = project.functionsErrorCount ?? 0;
|
||||
const quota = project.cpuTimeQuotaMsPerMinute ?? 0;
|
||||
const used = project.cpuTimeUsedMs ?? 0;
|
||||
const cpuPercent = quota
|
||||
? Math.min(100, (used / quota) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={project._id}
|
||||
onClick={() => handleOpenProject(project._id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleOpenProject(project._id);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={{ cursor: "pointer" }}
|
||||
role="button"
|
||||
>
|
||||
<td>
|
||||
<Stack>
|
||||
<Typography level="body-sm" fontWeight={600}>
|
||||
{project.name}
|
||||
</Typography>
|
||||
<Chip size="sm" variant="soft" color="neutral">
|
||||
{project.slug}
|
||||
</Chip>
|
||||
</Stack>
|
||||
</td>
|
||||
<td>
|
||||
<Typography level="body-sm" fontWeight={500}>
|
||||
{formatNumber(functionsCount)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography level="body-sm" fontWeight={500}>
|
||||
{formatNumber(invocations)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography
|
||||
level="body-sm"
|
||||
fontWeight={500}
|
||||
color={errors ? "danger" : undefined}
|
||||
>
|
||||
{formatNumber(errors)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography level="body-sm" fontWeight={500}>
|
||||
{cpuPercent.toFixed(1)}%
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Stack direction="row" gap={1} justifyContent="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
startDecorator={<PenSquare size={14} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEditProject(project._id);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Stack>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{ py: 6, px: 2 }}
|
||||
gap={0.75}
|
||||
>
|
||||
<Typography level="title-sm">No projects yet</Typography>
|
||||
<Typography
|
||||
level="body-sm"
|
||||
textColor="neutral.500"
|
||||
textAlign="center"
|
||||
>
|
||||
Spin up your first project to start deploying functions.
|
||||
</Typography>
|
||||
<Button
|
||||
size="sm"
|
||||
startDecorator={<Plus size={16} />}
|
||||
onClick={onCreateProject}
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Card, LinearProgress, Stack, Typography } from "@mui/joy";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { DashboardSummary } from "./types";
|
||||
|
||||
export function ServerLoadCard({ summary }: { summary: DashboardSummary }) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Stack gap={1.5}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Activity size={18} />
|
||||
<Typography level="title-sm" fontWeight={600}>
|
||||
Current server load
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography level="h1" fontSize="2.5rem">
|
||||
{summary.serverLoadPercent.toFixed(1)}%
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
determinate
|
||||
value={summary.serverLoadPercent}
|
||||
color={
|
||||
summary.serverLoadPercent > 85
|
||||
? "danger"
|
||||
: summary.serverLoadPercent > 60
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
sx={{
|
||||
borderRadius: "xl",
|
||||
"&::before": {
|
||||
transitionProperty: "inline-size",
|
||||
transitionDuration: "320ms",
|
||||
msTransitionTimingFunction: "ease-in-out",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, Typography } from "@mui/joy";
|
||||
|
||||
export type StatCardProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
statusColor?: "neutral" | "success" | "warning" | "danger";
|
||||
};
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
statusColor = "neutral",
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card variant="soft" color={statusColor}>
|
||||
<Typography level="title-sm" textColor="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography level="h2" mt={0.5}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
{caption}
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Grid } from "@mui/joy";
|
||||
|
||||
import { StatCard } from "./stat-card";
|
||||
import { DashboardSummary } from "./types";
|
||||
import { formatNumber } from "./number-format";
|
||||
|
||||
export function TopStatsGrid({ summary }: { summary: DashboardSummary }) {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12} sm={6} lg={3}>
|
||||
<StatCard
|
||||
label="Active projects"
|
||||
value={formatNumber(summary.totalProjects)}
|
||||
caption="Projects receiving traffic"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} lg={3}>
|
||||
<StatCard
|
||||
label="Functions"
|
||||
value={formatNumber(summary.totalFunctions)}
|
||||
caption="Deployed across all projects"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} lg={3}>
|
||||
<StatCard
|
||||
label="Invocations"
|
||||
value={formatNumber(summary.totalInvocations)}
|
||||
caption="Across all projects"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={6} lg={3}>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={formatNumber(summary.totalErrors)}
|
||||
caption="Functions reporting failures"
|
||||
statusColor={summary.totalErrors ? "danger" : "success"}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type DashboardSummary = {
|
||||
totalProjects: number;
|
||||
totalFunctions: number;
|
||||
totalInvocations: number;
|
||||
totalErrors: number;
|
||||
quotaUsed: number;
|
||||
quotaTotal: number;
|
||||
serverLoadPercent: number;
|
||||
};
|
||||
1
aws_sigma_service/panel/src/config.ts
Normal file
1
aws_sigma_service/panel/src/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const BACKEND_BASE = process.env["BACKEND_BASE"] || "/";
|
||||
235
aws_sigma_service/panel/src/context/auth-context.tsx
Normal file
235
aws_sigma_service/panel/src/context/auth-context.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { apiClient, configureApiAuth } from "../lib/api";
|
||||
import { UserDto } from "@sigma/common";
|
||||
|
||||
const STORAGE_KEY = "sigma.auth";
|
||||
|
||||
type LoginDto = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type RegisterDto = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
user: UserDto | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
login: (payload: LoginDto) => Promise<void>;
|
||||
register: (payload: RegisterDto) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
type SavedState = Pick<AuthContextValue, "user" | "token">;
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function readStoredAuth() {
|
||||
try {
|
||||
const json = localStorage.getItem(STORAGE_KEY);
|
||||
if (!json) return null;
|
||||
|
||||
return JSON.parse(json) as SavedState;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse stored auth", error);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [user, setUser] = useState<UserDto | null>(
|
||||
() => readStoredAuth()?.user ?? null
|
||||
);
|
||||
const [token, setToken] = useState<string | null>(
|
||||
() => readStoredAuth()?.token ?? null
|
||||
);
|
||||
const tokenRef = useRef(readStoredAuth()?.token ?? null);
|
||||
const logoutRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const persistAuth = useCallback((newUser: UserDto, newToken: string) => {
|
||||
setUser(newUser);
|
||||
setToken(newToken);
|
||||
tokenRef.current = newToken;
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ user: newUser, token: newToken })
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearAuth = useCallback(() => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
tokenRef.current = null;
|
||||
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
const fetchProfile = useCallback(
|
||||
async ({ tokenOverride }: { tokenOverride?: string } = {}) => {
|
||||
const activeToken = tokenOverride ?? token;
|
||||
if (!activeToken) {
|
||||
throw new Error("Missing auth token for profile request");
|
||||
}
|
||||
|
||||
const headers =
|
||||
tokenOverride !== undefined
|
||||
? { Authorization: `Bearer ${activeToken}` }
|
||||
: undefined;
|
||||
|
||||
const { data } = await apiClient.get<UserDto>("/users/me", {
|
||||
headers,
|
||||
});
|
||||
|
||||
persistAuth(data, activeToken);
|
||||
|
||||
return data;
|
||||
},
|
||||
[persistAuth, token]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuth();
|
||||
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { from: location.pathname },
|
||||
});
|
||||
}, [clearAuth, navigate, location.pathname]);
|
||||
|
||||
logoutRef.current = logout;
|
||||
|
||||
configureApiAuth({
|
||||
tokenProvider: () => tokenRef.current,
|
||||
onUnauthorized: () => logoutRef.current?.(),
|
||||
});
|
||||
|
||||
const authenticate = useCallback(
|
||||
async ({ username, password }: LoginDto) => {
|
||||
const { data } = await apiClient.post<{ auth_token?: string }>(
|
||||
"/auth/login",
|
||||
{
|
||||
username,
|
||||
password,
|
||||
}
|
||||
);
|
||||
|
||||
const newToken = data?.auth_token;
|
||||
if (!newToken) {
|
||||
throw new Error("Login response missing auth token");
|
||||
}
|
||||
|
||||
await fetchProfile({ tokenOverride: newToken });
|
||||
},
|
||||
[fetchProfile]
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
async (payload: LoginDto) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await authenticate(payload);
|
||||
|
||||
navigate("/", { replace: true });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[authenticate, navigate]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (payload: RegisterDto) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await apiClient.post("/users", payload);
|
||||
await authenticate(payload);
|
||||
|
||||
navigate("/", { replace: true });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[authenticate, navigate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
fetchProfile()
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
logout();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, user, fetchProfile, logout]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: Boolean(user && token),
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}),
|
||||
[user, token, loading, login, register, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useRequireAuth() {
|
||||
const context = useAuth();
|
||||
if (!context.isAuthenticated) {
|
||||
throw new Error("User is not authenticated");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
12
aws_sigma_service/panel/src/index.html
Normal file
12
aws_sigma_service/panel/src/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AWS Sigma</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
aws_sigma_service/panel/src/index.tsx
Normal file
10
aws_sigma_service/panel/src/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="./parcel.d.ts" />
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
|
||||
import "@fontsource/inter";
|
||||
|
||||
let container = document.getElementById("app")!;
|
||||
let root = createRoot(container);
|
||||
root.render(<App />);
|
||||
43
aws_sigma_service/panel/src/lib/api.ts
Normal file
43
aws_sigma_service/panel/src/lib/api.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import axios from "axios";
|
||||
import { BACKEND_BASE } from "../config";
|
||||
|
||||
type TokenSupplier = () => string | null;
|
||||
type UnauthorizedHandler = () => void;
|
||||
|
||||
let tokenSupplier: TokenSupplier = () => null;
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: `${BACKEND_BASE}api/v1`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = tokenSupplier();
|
||||
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export function configureApiAuth(options: {
|
||||
tokenProvider: TokenSupplier;
|
||||
onUnauthorized?: UnauthorizedHandler;
|
||||
}) {
|
||||
tokenSupplier = options.tokenProvider;
|
||||
unauthorizedHandler = options.onUnauthorized ?? null;
|
||||
}
|
||||
21
aws_sigma_service/panel/src/lib/editor.ts
Normal file
21
aws_sigma_service/panel/src/lib/editor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { useMonaco } from "@monaco-editor/react";
|
||||
import typings from "bundle-text:../../../executor/src/builtins/builtins.d.ts";
|
||||
|
||||
const TYPINGS_URI = "ts:platform.d.ts";
|
||||
|
||||
export function configureEditor(
|
||||
monaco: NonNullable<ReturnType<typeof useMonaco>>
|
||||
) {
|
||||
monaco.typescript.javascriptDefaults.setCompilerOptions({
|
||||
target: monaco.typescript.ScriptTarget.ES2015,
|
||||
allowNonTsExtensions: true, // Fix the very weird bug. Don't touch. EVER.
|
||||
});
|
||||
|
||||
monaco.typescript.javascriptDefaults.addExtraLib(typings, TYPINGS_URI);
|
||||
|
||||
const uri = monaco.Uri.parse(TYPINGS_URI);
|
||||
|
||||
if (!monaco.editor.getModel(uri)) {
|
||||
monaco.editor.createModel(typings, "typescript");
|
||||
}
|
||||
}
|
||||
186
aws_sigma_service/panel/src/pages/dashboard.tsx
Normal file
186
aws_sigma_service/panel/src/pages/dashboard.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/joy";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { useAuth } from "../context/auth-context";
|
||||
import { TopStatsGrid } from "../components/dashboard/top-stats-grid";
|
||||
import { ServerLoadCard } from "../components/dashboard/server-load-card";
|
||||
import { ProjectsListCard } from "../components/dashboard/projects-list-card";
|
||||
import { CpuInfoDto, ProjectDto } from "@sigma/common";
|
||||
import { DashboardSummary } from "../components/dashboard/types";
|
||||
|
||||
function buildDashboardSummary(
|
||||
projects: ProjectDto[],
|
||||
cpuUsage: number
|
||||
): DashboardSummary {
|
||||
const totals = projects.reduce(
|
||||
(acc, project) => {
|
||||
acc.totalFunctions += project.functions?.length ?? 0;
|
||||
acc.totalInvocations += project.functionsInvocationCount ?? 0;
|
||||
acc.totalErrors += project.functionsErrorCount ?? 0;
|
||||
acc.quotaTotal += project.cpuTimeQuotaMsPerMinute ?? 0;
|
||||
acc.quotaUsed += project.cpuTimeUsedMs ?? 0;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalFunctions: 0,
|
||||
totalInvocations: 0,
|
||||
totalErrors: 0,
|
||||
quotaTotal: 0,
|
||||
quotaUsed: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
totalProjects: projects.length,
|
||||
totalFunctions: totals.totalFunctions,
|
||||
totalInvocations: totals.totalInvocations,
|
||||
totalErrors: totals.totalErrors,
|
||||
quotaUsed: totals.quotaUsed,
|
||||
quotaTotal: totals.quotaTotal,
|
||||
serverLoadPercent: cpuUsage,
|
||||
};
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||
const [cpuUsage, setCpuUsage] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<ProjectDto[]>("/projects");
|
||||
setProjects(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Unable to load projects right now"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateCpuUsage = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get<CpuInfoDto>("/health/cpu");
|
||||
|
||||
setCpuUsage(data.usage);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CPU usage:", err);
|
||||
}
|
||||
}, [setCpuUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => updateCpuUsage(), 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [updateCpuUsage]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => buildDashboardSummary(projects, cpuUsage),
|
||||
[projects, cpuUsage]
|
||||
);
|
||||
|
||||
const handleCreateProject = () => {
|
||||
navigate("/projects/new");
|
||||
};
|
||||
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname ?? "/" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography level="h2" fontWeight={700}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Realtime overview of project health and usage
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
{lastUpdated ? (
|
||||
<Typography level="body-xs" textColor="neutral.500">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
startDecorator={<RefreshCw size={16} />}
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
onClick={fetchProjects}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ py: 6 }}>
|
||||
<CircularProgress size="lg" />
|
||||
<Typography level="body-sm" textColor="neutral.500" mt={1.5}>
|
||||
Loading usage data…
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : error ? (
|
||||
<Card variant="soft" color="danger">
|
||||
<Stack direction="row" alignItems="center" gap={1.5}>
|
||||
<AlertTriangle size={20} />
|
||||
<Box>
|
||||
<Typography level="title-sm">Unable to load dashboard</Typography>
|
||||
<Typography level="body-sm">{error}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<TopStatsGrid summary={summary} />
|
||||
|
||||
<Grid container spacing={2} mt={2}>
|
||||
<Grid xs={12} lg={8}>
|
||||
<ProjectsListCard
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} lg={4}>
|
||||
<ServerLoadCard summary={summary} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
121
aws_sigma_service/panel/src/pages/layout.tsx
Normal file
121
aws_sigma_service/panel/src/pages/layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dropdown,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from "@mui/joy";
|
||||
import { ChevronDown, CircleUserRound, LogOut, Server } from "lucide-react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
import { useAuth } from "../context/auth-context";
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
py: 2,
|
||||
px: 3,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "neutral.outlinedBorder",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #5B8DEF 0%, #A855F7 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
boxShadow: "sm",
|
||||
}}
|
||||
>
|
||||
<Server size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
{Math.random() > 0.1 ? (
|
||||
<>
|
||||
<Typography level="title-md" fontWeight={600}>
|
||||
AWS Sigma
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Our serverless functions are the most serverless in the world
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography level="title-md" fontWeight={600}>
|
||||
AWS Ligma
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
What's ligma?
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{isAuthenticated ? (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
pl: 1,
|
||||
pr: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: "xl",
|
||||
border: "1px solid",
|
||||
borderColor: "neutral.outlinedBorder",
|
||||
boxShadow: "sm",
|
||||
}}
|
||||
endDecorator={<ChevronDown size={16} />}
|
||||
>
|
||||
<Box sx={{ textAlign: "left" }}>
|
||||
<Typography level="body-sm" fontWeight={600}>
|
||||
{user?.username}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuButton>
|
||||
|
||||
<Menu placement="bottom-end">
|
||||
<MenuItem color="danger" variant="solid" onClick={logout}>
|
||||
<LogOut size={16} />
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
startDecorator={<CircleUserRound size={18} />}
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
aws_sigma_service/panel/src/pages/login.tsx
Normal file
215
aws_sigma_service/panel/src/pages/login.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
import { useAuth } from "../context/auth-context";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
export function Login() {
|
||||
const location = useLocation();
|
||||
const { login, register, loading, isAuthenticated } = useAuth();
|
||||
const [mode, setMode] = useState<AuthMode>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [registerUsername, setRegisterUsername] = useState("");
|
||||
const [registerPassword, setRegisterPassword] = useState("");
|
||||
const [registerConfirm, setRegisterConfirm] = useState("");
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [registerError, setRegisterError] = useState<string | null>(null);
|
||||
|
||||
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
const handleLoginSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLoginError(null);
|
||||
|
||||
try {
|
||||
await login({ username, password });
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||
setLoginError("Invalid username or password");
|
||||
} else if (err instanceof Error) {
|
||||
setLoginError(err.message);
|
||||
} else {
|
||||
setLoginError("Login failed");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterSubmit = async (
|
||||
event: React.FormEvent<HTMLFormElement>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
setRegisterError(null);
|
||||
|
||||
if (registerPassword.length < 8) {
|
||||
setRegisterError("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerPassword !== registerConfirm) {
|
||||
setRegisterError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register({
|
||||
username: registerUsername,
|
||||
password: registerPassword,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 409) {
|
||||
setRegisterError("Username already exists");
|
||||
} else if (err instanceof Error) {
|
||||
setRegisterError(err.message);
|
||||
} else {
|
||||
setRegisterError("Registration failed");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "background.level1",
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Card sx={{ width: "100%", maxWidth: 420, p: 3, gap: 2 }}>
|
||||
<Box>
|
||||
<Typography level="title-lg" fontWeight={600}>
|
||||
{mode === "login" ? "Welcome back" : "Create an account"}
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
{mode === "login"
|
||||
? "Sign in to manage your AWS Sigma functions"
|
||||
: "Register a new operator account for AWS Sigma"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={(_event, value) => setMode(value as AuthMode)}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<TabList
|
||||
sx={{
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
<Tab value="login">Sign in</Tab>
|
||||
<Tab value="register">Register</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="login" sx={{ px: 0 }}>
|
||||
<form onSubmit={handleLoginSubmit}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<FormControl required>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ogurechek"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl required>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
{loginError && (
|
||||
<Alert color="danger" variant="soft">
|
||||
{loginError}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading && mode === "login"}
|
||||
disabled={!username || !password}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</TabPanel>
|
||||
<TabPanel value="register" sx={{ px: 0 }}>
|
||||
<form onSubmit={handleRegisterSubmit}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<FormControl required>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ogurechek"
|
||||
value={registerUsername}
|
||||
onChange={(event) =>
|
||||
setRegisterUsername(event.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl required>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Minimum 8 characters"
|
||||
value={registerPassword}
|
||||
onChange={(event) =>
|
||||
setRegisterPassword(event.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl required>
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
value={registerConfirm}
|
||||
onChange={(event) => setRegisterConfirm(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
{registerError && (
|
||||
<Alert color="danger" variant="soft">
|
||||
{registerError}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading && mode === "register"}
|
||||
disabled={
|
||||
!registerUsername || !registerPassword || !registerConfirm
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
571
aws_sigma_service/panel/src/pages/projects/detail.tsx
Normal file
571
aws_sigma_service/panel/src/pages/projects/detail.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Table,
|
||||
Typography,
|
||||
type ColorPaletteProp,
|
||||
} from "@mui/joy";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Layers3,
|
||||
PenSquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||
import type { ProjectDto } from "@sigma/common";
|
||||
import { apiClient } from "../../lib/api";
|
||||
import { useAuth } from "../../context/auth-context";
|
||||
import { formatNumber } from "../../components/dashboard/number-format";
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
color?: ColorPaletteProp;
|
||||
};
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingFunctionId, setDeletingFunctionId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [projectDeleteError, setProjectDeleteError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [deletingProject, setDeletingProject] = useState(false);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setError("Missing project identifier");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<ProjectDto>(
|
||||
`/projects/${projectId}`
|
||||
);
|
||||
setProject(data);
|
||||
setDeleteError(null);
|
||||
setProjectDeleteError(null);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
setProject(null);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Unable to load project details"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchProject();
|
||||
}, [fetchProject, isAuthenticated]);
|
||||
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname ?? "/" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 2) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
const cpuUsagePercent = useMemo(() => {
|
||||
if (!project) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!project.cpuTimeQuotaMsPerMinute) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
(project.cpuTimeUsedMs / project.cpuTimeQuotaMsPerMinute) * 100
|
||||
);
|
||||
}, [project]);
|
||||
|
||||
const stats = useMemo<StatCard[]>(() => {
|
||||
if (!project) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const windowStart = new Date(project.cpuTimeWindowStartedAt);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Functions",
|
||||
value: formatNumber(project.functions.length),
|
||||
description: "Deployed handlers in this project",
|
||||
},
|
||||
{
|
||||
label: "Invocations",
|
||||
value: formatNumber(project.functionsInvocationCount ?? 0),
|
||||
description: "Aggregate executions across all functions",
|
||||
},
|
||||
{
|
||||
label: "Errors",
|
||||
value: formatNumber(project.functionsErrorCount ?? 0),
|
||||
description: "Functions failed invocations count",
|
||||
color: (project.functionsErrorCount ?? 0) > 0 ? "danger" : undefined,
|
||||
},
|
||||
{
|
||||
label: "CPU quota (ms/min)",
|
||||
value: formatNumber(project.cpuTimeQuotaMsPerMinute ?? 0),
|
||||
description: "Configured execution budget",
|
||||
},
|
||||
{
|
||||
label: "CPU used",
|
||||
value: `${formatNumber(project.cpuTimeUsedMs ?? 0)} ms (${cpuUsagePercent.toFixed(1)}%)`,
|
||||
description: "Usage during the current window",
|
||||
},
|
||||
{
|
||||
label: "Window started",
|
||||
value: windowStart.toLocaleString(),
|
||||
description: "Quota refresh timestamp",
|
||||
},
|
||||
];
|
||||
}, [project, cpuUsagePercent]);
|
||||
|
||||
const sortedFunctions = useMemo(() => {
|
||||
if (!project) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...project.functions].sort(
|
||||
(a, b) => (b.invocations ?? 0) - (a.invocations ?? 0)
|
||||
);
|
||||
}, [project]);
|
||||
|
||||
const handleCreateFunction = () => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/projects/${projectId}/functions/new`);
|
||||
};
|
||||
|
||||
const handleDeleteProject = useCallback(async () => {
|
||||
if (!projectId || !project) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed =
|
||||
typeof window === "undefined"
|
||||
? true
|
||||
: window.confirm(
|
||||
`Delete project "${project.name}" and all of its functions? This cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingProject(true);
|
||||
setProjectDeleteError(null);
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to delete project";
|
||||
setProjectDeleteError(message);
|
||||
} finally {
|
||||
setDeletingProject(false);
|
||||
}
|
||||
}, [navigate, project, projectId]);
|
||||
|
||||
const handleDeleteFunction = useCallback(
|
||||
async (functionId: string, functionName?: string) => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLabel = functionName ? `"${functionName}"` : "this function";
|
||||
const confirmed =
|
||||
typeof window === "undefined"
|
||||
? true
|
||||
: window.confirm(
|
||||
`Delete function ${targetLabel}? This cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingFunctionId(functionId);
|
||||
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/functions/${functionId}`
|
||||
);
|
||||
|
||||
setProject((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const removed = prev.functions.find((f) => f._id === functionId);
|
||||
if (!removed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const remainingFunctions = prev.functions.filter(
|
||||
(f) => f._id !== functionId
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
functions: remainingFunctions,
|
||||
functionsInvocationCount: Math.max(
|
||||
0,
|
||||
(prev.functionsInvocationCount ?? 0) - (removed.invocations ?? 0)
|
||||
),
|
||||
functionsErrorCount: Math.max(
|
||||
0,
|
||||
(prev.functionsErrorCount ?? 0) - (removed.errors ?? 0)
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to delete function";
|
||||
setDeleteError(message);
|
||||
} finally {
|
||||
setDeletingFunctionId(null);
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Button
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
startDecorator={<ArrowLeft size={16} />}
|
||||
onClick={handleBack}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Back to projects
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ py: 6 }}>
|
||||
<CircularProgress size="lg" />
|
||||
<Typography level="body-sm" textColor="neutral.500" mt={1.5}>
|
||||
Loading project insights…
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : error ? (
|
||||
<Card variant="soft" color="danger">
|
||||
<Stack direction="row" gap={1.5} alignItems="center">
|
||||
<Alert color="danger" variant="plain">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
startDecorator={<RefreshCw size={16} />}
|
||||
onClick={fetchProject}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : project ? (
|
||||
<Stack gap={2}>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: "flex-start", md: "center" }}
|
||||
gap={2}
|
||||
>
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Typography level="h2" fontWeight={700}>
|
||||
{project.name}
|
||||
</Typography>
|
||||
<Chip size="sm" variant="soft" color="neutral">
|
||||
{project.slug}
|
||||
</Chip>
|
||||
</Stack>
|
||||
{project.description ? (
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
{project.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
{lastUpdated ? (
|
||||
<Typography
|
||||
level="body-xs"
|
||||
textColor="neutral.500"
|
||||
sx={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
startDecorator={<RefreshCw size={16} />}
|
||||
onClick={fetchProject}
|
||||
disabled={loading || deletingProject}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
startDecorator={<Trash2 size={16} />}
|
||||
onClick={handleDeleteProject}
|
||||
loading={deletingProject}
|
||||
disabled={deletingProject}
|
||||
>
|
||||
Delete project
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{projectDeleteError ? (
|
||||
<Alert color="danger" variant="soft">
|
||||
{projectDeleteError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid container spacing={2} mb={2} sx={{ mt: 1 }}>
|
||||
{stats.map((stat) => (
|
||||
<Grid key={stat.label} xs={12} sm={6} lg={4}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{ p: 2.25, gap: 0.5 }}
|
||||
color={stat.color}
|
||||
>
|
||||
<Typography level="body-xs" textColor="neutral.500">
|
||||
{stat.label}
|
||||
</Typography>
|
||||
<Typography level="title-md" fontWeight={600}>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
<Typography level="body-xs" textColor="neutral.500">
|
||||
{stat.description}
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ p: 0 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 1.75,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={1.25}>
|
||||
<Layers3 size={18} />
|
||||
<Stack gap={0.25}>
|
||||
<Typography level="title-sm" fontWeight={600}>
|
||||
Functions
|
||||
</Typography>
|
||||
<Typography level="body-xs" textColor="neutral.500">
|
||||
Invocation and error metrics per endpoint
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Chip size="sm" variant="soft">
|
||||
{project.functions.length} total
|
||||
</Chip>
|
||||
<Button
|
||||
size="sm"
|
||||
startDecorator={<Plus size={16} />}
|
||||
onClick={handleCreateFunction}
|
||||
>
|
||||
Create function
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{deleteError ? (
|
||||
<Alert color="danger" variant="soft" sx={{ mx: 2.5, my: 1.5 }}>
|
||||
{deleteError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{sortedFunctions.length ? (
|
||||
<Table
|
||||
size="sm"
|
||||
stickyHeader
|
||||
sx={{
|
||||
"--TableCell-headBackground": "transparent",
|
||||
"--TableCell-paddingX": "20px",
|
||||
"--TableCell-paddingY": "12px",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "16%" }}>Function</th>
|
||||
<th style={{ width: "16%" }}>Methods</th>
|
||||
<th style={{ width: "16%" }}>Invocations</th>
|
||||
<th style={{ width: "16%" }}>Errors</th>
|
||||
<th style={{ width: "16%" }}>Last invocation</th>
|
||||
<th style={{ width: "16%" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFunctions.map((func) => {
|
||||
const methods: string[] = func.methods?.length
|
||||
? func.methods
|
||||
: ["ANY"];
|
||||
|
||||
return (
|
||||
<tr key={func._id}>
|
||||
<td>
|
||||
<Stack gap={0.25}>
|
||||
<Typography level="body-sm" fontWeight={600}>
|
||||
{func.name}
|
||||
</Typography>
|
||||
<Typography level="body-xs" textColor="neutral.500">
|
||||
{func.path}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</td>
|
||||
<td>
|
||||
<Stack direction="row" gap={0.5} flexWrap="wrap">
|
||||
{methods.map((method) => (
|
||||
<Chip key={method} size="sm" variant="outlined">
|
||||
{method}
|
||||
</Chip>
|
||||
))}
|
||||
</Stack>
|
||||
</td>
|
||||
<td>
|
||||
<Typography level="body-sm" fontWeight={500}>
|
||||
{formatNumber(func.invocations ?? 0)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography
|
||||
level="body-sm"
|
||||
fontWeight={500}
|
||||
color={func.errors ? "danger" : undefined}
|
||||
>
|
||||
{formatNumber(func.errors ?? 0)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography
|
||||
level="body-xs"
|
||||
textColor={
|
||||
func.lastInvocation ? undefined : "neutral.500"
|
||||
}
|
||||
>
|
||||
{func.lastInvocation?.toLocaleString() ?? "Never"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={1}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
startDecorator={<PenSquare size={14} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projects/${projectId}/functions/${func._id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
startDecorator={<Trash2 size={14} />}
|
||||
onClick={() =>
|
||||
handleDeleteFunction(func._id, func.name)
|
||||
}
|
||||
loading={deletingFunctionId === func._id}
|
||||
disabled={deletingFunctionId === func._id}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ py: 5 }}>
|
||||
<Typography level="title-sm">No functions deployed</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Once you add functions, their metrics will show up here.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
) : (
|
||||
<Card variant="soft">
|
||||
<Typography level="title-sm">
|
||||
We couldn’t find that project. Double-check the URL and try again.
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
493
aws_sigma_service/panel/src/pages/projects/functions/detail.tsx
Normal file
493
aws_sigma_service/panel/src/pages/projects/functions/detail.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Link,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from "@mui/joy";
|
||||
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||
import { ArrowLeft, RefreshCw, Save } from "lucide-react";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
import type { FunctionDto, ProjectDto, UpdateFunctionDto } from "@sigma/common";
|
||||
import { apiClient } from "../../../lib/api";
|
||||
import { useAuth } from "../../../context/auth-context";
|
||||
import { configureEditor } from "../../../lib/editor";
|
||||
import { BACKEND_BASE } from "../../../config";
|
||||
|
||||
const PATH_REGEX =
|
||||
/^\/(?:$|(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+)(?:\/(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+))*)$/;
|
||||
|
||||
const HTTP_METHODS = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"HEAD",
|
||||
];
|
||||
|
||||
function sanitizeMethods(values?: string[] | null): string[] {
|
||||
if (!values?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(new Set(values.map((method) => method.toUpperCase())));
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
if (!value.startsWith("/")) {
|
||||
return `/${value}`;
|
||||
}
|
||||
|
||||
if (value.length > 1 && value.endsWith("/")) {
|
||||
return value.slice(0, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
type UpdateFunctionPayload = UpdateFunctionDto & {
|
||||
methods?: string[];
|
||||
};
|
||||
|
||||
export function FunctionDetailPage() {
|
||||
const monaco = useMonaco();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { projectId, functionId } = useParams<{
|
||||
projectId: string;
|
||||
functionId: string;
|
||||
}>();
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [func, setFunc] = useState<FunctionDto | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [path, setPath] = useState("/");
|
||||
const [methods, setMethods] = useState<string[]>([]);
|
||||
const [code, setCode] = useState<string>(
|
||||
"// Write your function code here\n"
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const normalizedPath = useMemo(
|
||||
() => normalizePath(path.trim() || "/"),
|
||||
[path]
|
||||
);
|
||||
const pathIsValid = PATH_REGEX.test(normalizedPath);
|
||||
const canSave = Boolean(name.trim()) && pathIsValid && !saving;
|
||||
const functionUrl = useMemo(() => {
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedBase = BACKEND_BASE.replace(/\/+$/, "");
|
||||
const execBase = `${trimmedBase ? trimmedBase : ""}/api/v1/exec/${project.slug}`;
|
||||
const invocationPath = normalizedPath === "/" ? "" : normalizedPath;
|
||||
|
||||
return `${execBase}${invocationPath}`;
|
||||
}, [project, normalizedPath]);
|
||||
|
||||
const logs = useMemo(() => func?.logs ?? [], [func]);
|
||||
const hasLogs = logs.length > 0;
|
||||
const logsCountLabel = hasLogs
|
||||
? `${logs.length} entr${logs.length === 1 ? "y" : "ies"}`
|
||||
: "No logs yet";
|
||||
const logsValue = useMemo(
|
||||
() => (hasLogs ? [...logs].reverse().join("\n") : ""),
|
||||
[hasLogs, logs]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
configureEditor(monaco);
|
||||
}
|
||||
}, [monaco]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 2) {
|
||||
navigate(-1);
|
||||
} else if (projectId) {
|
||||
navigate(`/projects/${projectId}`);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
const loadFunction = useCallback(async () => {
|
||||
if (!projectId || !functionId) {
|
||||
setError("Missing identifiers for project or function");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<ProjectDto>(
|
||||
`/projects/${projectId}`
|
||||
);
|
||||
const matchingFunction = data.functions.find(
|
||||
(item) => item._id === functionId
|
||||
);
|
||||
|
||||
if (!matchingFunction) {
|
||||
setError("We couldn't find that function in this project.");
|
||||
setProject(data);
|
||||
setFunc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProject(data);
|
||||
setFunc(matchingFunction);
|
||||
setName(matchingFunction.name ?? "");
|
||||
setPath(matchingFunction.path ?? "/");
|
||||
setMethods(sanitizeMethods(matchingFunction.methods));
|
||||
setCode(matchingFunction.code ?? "");
|
||||
setSuccess(null);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to load function details. Please try again.";
|
||||
setError(message);
|
||||
setFunc(null);
|
||||
setProject(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, functionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadFunction();
|
||||
}, [isAuthenticated, loadFunction]);
|
||||
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname ?? "/" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectId || !functionId) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert color="danger" variant="soft">
|
||||
Missing project or function identifier. Please return to the dashboard
|
||||
and try again.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const handleMethodsChange = (_: unknown, newValue: string[] | null) => {
|
||||
setMethods(newValue?.map((method) => method.toUpperCase()) ?? []);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSave || !projectId || !functionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
const payload: UpdateFunctionPayload = {
|
||||
name: name.trim(),
|
||||
path: normalizedPath,
|
||||
code: code ?? "",
|
||||
methods: methods.length ? methods : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.put<FunctionDto>(
|
||||
`/projects/${projectId}/functions/${functionId}`,
|
||||
payload
|
||||
);
|
||||
|
||||
setFunc(data);
|
||||
setName(data.name);
|
||||
setPath(data.path);
|
||||
setMethods(sanitizeMethods(data.methods));
|
||||
setCode(data.code ?? "");
|
||||
setSuccess("Function updated successfully.");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to save function.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 960,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
startDecorator={<ArrowLeft size={16} />}
|
||||
onClick={handleBack}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Back to project
|
||||
</Button>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: "flex-start", md: "center" }}
|
||||
flexWrap="wrap"
|
||||
gap={1.5}
|
||||
>
|
||||
<Box>
|
||||
<Typography level="h2" fontWeight={700}>
|
||||
{func?.name ?? "Function"}
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
{project
|
||||
? `${project.name} · ${func?.path ?? ""}`
|
||||
: "Loading project..."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
startDecorator={<RefreshCw size={16} />}
|
||||
onClick={loadFunction}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error ? (
|
||||
<Alert color="danger" variant="soft">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{success ? (
|
||||
<Alert color="success" variant="soft">
|
||||
{success}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card sx={{ p: 3 }}>
|
||||
{loading ? (
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Loading function details...
|
||||
</Typography>
|
||||
) : func ? (
|
||||
<Stack component="form" gap={2.5} onSubmit={handleSubmit}>
|
||||
<FormControl required>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
placeholder="Webhook handler"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl required error={!pathIsValid}>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<Input
|
||||
placeholder="/webhooks/payments/:id"
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Must start with /. Use :param for dynamic segments.
|
||||
</FormHelperText>
|
||||
{project && pathIsValid && functionUrl ? (
|
||||
<FormHelperText>
|
||||
Available at
|
||||
<Link
|
||||
href={functionUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{functionUrl}
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
) : null}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Allowed methods</FormLabel>
|
||||
<Select
|
||||
multiple
|
||||
placeholder="Any method"
|
||||
value={methods}
|
||||
onChange={handleMethodsChange}
|
||||
>
|
||||
{HTTP_METHODS.map((method) => (
|
||||
<Option key={method} value={method}>
|
||||
{method}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{methods.length ? (
|
||||
<Stack direction="row" gap={0.5} flexWrap="wrap" mt={1}>
|
||||
{methods.map((method) => (
|
||||
<Chip key={method} size="sm" variant="soft">
|
||||
{method}
|
||||
</Chip>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
<FormHelperText>
|
||||
Leave empty to accept any HTTP method.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Code</FormLabel>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: "md",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
height="420px"
|
||||
defaultLanguage="javascript"
|
||||
theme="vs-light"
|
||||
value={code}
|
||||
onChange={(value: string | undefined) =>
|
||||
setCode(value ?? "")
|
||||
}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
tabSize: 2,
|
||||
fontSize: 14,
|
||||
automaticLayout: true,
|
||||
fixedOverflowWidgets: true,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
<Typography fontSize="inherit">
|
||||
Write your function code in plain JavaScript.
|
||||
<br />
|
||||
Use{" "}
|
||||
<Typography variant="soft" fontSize="inherit">
|
||||
req
|
||||
</Typography>{" "}
|
||||
and{" "}
|
||||
<Typography variant="soft" fontSize="inherit">
|
||||
res
|
||||
</Typography>{" "}
|
||||
objects to handle requests and responses.
|
||||
<br />
|
||||
You can use{" "}
|
||||
<Typography variant="soft" fontSize="inherit">
|
||||
{`scope_${project?.slug}`}
|
||||
</Typography>{" "}
|
||||
table within your SQL queries. Just be sure to create it
|
||||
first.
|
||||
</Typography>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
mb={0.5}
|
||||
>
|
||||
<FormLabel sx={{ mb: 0 }}>Recent logs</FormLabel>
|
||||
<Chip size="sm" variant="soft" color="neutral">
|
||||
{logsCountLabel}
|
||||
</Chip>
|
||||
</Stack>
|
||||
<Textarea
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
value={logsValue}
|
||||
readOnly
|
||||
placeholder="Logs will appear here after the function runs."
|
||||
sx={{
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
|
||||
bgcolor: "background.level1",
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
{hasLogs
|
||||
? "Newest entries are shown first. Use Refresh to pull the latest logs."
|
||||
: "Invoke this function to generate logs, then use Refresh to load them."}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Stack direction="row" gap={1.5} justifyContent="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
onClick={handleBack}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
startDecorator={<Save size={18} />}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Select a function from the project to begin editing.
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
204
aws_sigma_service/panel/src/pages/projects/functions/new.tsx
Normal file
204
aws_sigma_service/panel/src/pages/projects/functions/new.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/joy";
|
||||
import { AxiosError } from "axios";
|
||||
import { ArrowLeft, Code2 } from "lucide-react";
|
||||
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||
import type { CreateFunctionDto } from "@sigma/common";
|
||||
import { useAuth } from "../../../context/auth-context";
|
||||
import { apiClient } from "../../../lib/api";
|
||||
|
||||
const PATH_REGEX =
|
||||
/^\/(?:$|(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+)(?:\/(?:[A-Za-z0-9._~-]+|:[A-Za-z0-9_]+))*)$/;
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
if (!value.startsWith("/")) {
|
||||
return `/${value}`;
|
||||
}
|
||||
|
||||
if (value.length > 1 && value.endsWith("/")) {
|
||||
return value.slice(0, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function CreateFunctionPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [path, setPath] = useState("/");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname ?? "/" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert color="danger" variant="soft">
|
||||
Missing project identifier. Please return to the dashboard and try
|
||||
again.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePath(path.trim() || "/");
|
||||
const pathIsValid = PATH_REGEX.test(normalizedPath);
|
||||
const canSubmit = Boolean(name.trim()) && pathIsValid && !submitting;
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 2) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate(`/projects/${projectId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const payload: CreateFunctionDto = {
|
||||
name: name.trim(),
|
||||
path: normalizedPath,
|
||||
};
|
||||
|
||||
try {
|
||||
await apiClient.post(`/projects/${projectId}/functions`, payload);
|
||||
navigate(`/projects/${projectId}`, { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.response?.data?.message) {
|
||||
const message = Array.isArray(err.response.data.message)
|
||||
? err.response.data.message.join("; ")
|
||||
: err.response.data.message;
|
||||
setError(message ?? "Failed to create function");
|
||||
} else {
|
||||
setError(err.message || "Failed to create function");
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Failed to create function");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 640,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
startDecorator={<ArrowLeft size={16} />}
|
||||
onClick={handleBack}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Back to project
|
||||
</Button>
|
||||
|
||||
<Box>
|
||||
<Typography level="h2" fontWeight={700}>
|
||||
New function
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Deploy a new handler for this project.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Stack component="form" gap={2.5} onSubmit={handleSubmit}>
|
||||
<FormControl required>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
placeholder="Webhook handler"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Used in the dashboard and logs to identify this function.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl required error={!pathIsValid}>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<Input
|
||||
placeholder="/webhooks/payments/:id"
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Must start with /. Use :param for dynamic segments.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{error ? (
|
||||
<Alert color="danger" variant="soft">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" gap={1.5} justifyContent="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
onClick={handleBack}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
startDecorator={<Code2 size={18} />}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Create function
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
240
aws_sigma_service/panel/src/pages/projects/new.tsx
Normal file
240
aws_sigma_service/panel/src/pages/projects/new.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Stack,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from "@mui/joy";
|
||||
import { AxiosError } from "axios";
|
||||
import { ArrowLeft, Rocket } from "lucide-react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||
import type { CreateProjectDto } from "@sigma/common";
|
||||
import { useAuth } from "../../context/auth-context";
|
||||
import { apiClient } from "../../lib/api";
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const DEFAULT_CPU_QUOTA = 1000;
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
export function CreateProjectPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [cpuQuota, setCpuQuota] = useState<string>("");
|
||||
const [slugEdited, setSlugEdited] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname ?? "/projects/new" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const slugIsValid = !slug || SLUG_REGEX.test(slug);
|
||||
const quotaNumber = cpuQuota ? Number(cpuQuota) : undefined;
|
||||
const quotaIsValid =
|
||||
quotaNumber === undefined ||
|
||||
(Number.isFinite(quotaNumber) && quotaNumber >= 1);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(name.trim()) &&
|
||||
Boolean(slug.trim()) &&
|
||||
Boolean(description.trim()) &&
|
||||
slugIsValid &&
|
||||
quotaIsValid &&
|
||||
!submitting;
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugEdited) {
|
||||
setSlug(slugify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
setSlugEdited(true);
|
||||
setSlug(slugify(value));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const payload: CreateProjectDto = {
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
description: description.trim(),
|
||||
};
|
||||
|
||||
if (quotaNumber !== undefined) {
|
||||
payload.cpuTimeQuotaMsPerMinute = quotaNumber;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post("/projects", payload);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.response?.status === 409) {
|
||||
setError("A project with this slug already exists.");
|
||||
} else if (err.response?.data?.message) {
|
||||
const message = Array.isArray(err.response.data.message)
|
||||
? err.response.data.message.join("; ")
|
||||
: err.response.data.message;
|
||||
setError(message ?? "Failed to create project");
|
||||
} else {
|
||||
setError(err.message || "Failed to create project");
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Failed to create project");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 2) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
startDecorator={<ArrowLeft size={16} />}
|
||||
onClick={handleBack}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Box>
|
||||
<Typography level="h2" fontWeight={700}>
|
||||
New project
|
||||
</Typography>
|
||||
<Typography level="body-sm" textColor="neutral.500">
|
||||
Define the essentials for a new compute project. You can add
|
||||
functions right after this step.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Stack component="form" gap={2.5} onSubmit={handleSubmit}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={2}>
|
||||
<FormControl required sx={{ flex: 1 }}>
|
||||
<FormLabel>Project name</FormLabel>
|
||||
<Input
|
||||
placeholder="Payments service"
|
||||
value={name}
|
||||
onChange={(event) => handleNameChange(event.target.value)}
|
||||
/>
|
||||
<FormHelperText>Shown across the dashboard.</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
required
|
||||
sx={{ flex: 1 }}
|
||||
error={Boolean(slug) && !slugIsValid}
|
||||
>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<Input
|
||||
placeholder="payments-service"
|
||||
value={slug}
|
||||
onChange={(event) => handleSlugChange(event.target.value)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Lowercase letters, numbers, and dashes only ({"/projects/"}
|
||||
{slug || "slug"}).
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<FormControl required>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
minRows={3}
|
||||
placeholder="Briefly describe what this project powers"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Helps teammates understand the workload.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{error ? (
|
||||
<Alert color="danger" variant="soft">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" gap={1.5} justifyContent="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
onClick={handleBack}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
startDecorator={<Rocket size={18} />}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
4
aws_sigma_service/panel/src/parcel.d.ts
vendored
Normal file
4
aws_sigma_service/panel/src/parcel.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "bundle-text:*" {
|
||||
const contents: string;
|
||||
export default contents;
|
||||
}
|
||||
29
aws_sigma_service/panel/tsconfig.json
Normal file
29
aws_sigma_service/panel/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
/* Modules */
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
/* Emit */
|
||||
"noEmit": true,
|
||||
|
||||
/* Interop Constraints */
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true,
|
||||
|
||||
/* Completeness */
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user