Update Next.js, Material UI

This commit is contained in:
David Stotijn 2022-01-28 20:20:15 +01:00
parent 73ebb89863
commit aa8ddf4122
No known key found for this signature in database
GPG key ID: B23243A9C47CEE2D
42 changed files with 2777 additions and 6026 deletions

View file

@ -1,7 +1,7 @@
before:
hooks:
- make clean
- make build-admin
- sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
- go mod tidy
builds:

6
admin/.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-css-tags": "off"
}
}

4
admin/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
/.next/
/out/
/build
/coverage

3
admin/.prettierrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 120
}

5
admin/next-env.d.ts vendored
View file

@ -1,2 +1,5 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -1,7 +1,10 @@
const withCSS = require("@zeit/next-css");
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
// @ts-check
module.exports = withCSS({
/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
reactStrictMode: true,
trailingSlash: true,
async rewrites() {
return [
@ -11,24 +14,6 @@ module.exports = withCSS({
},
];
},
webpack: (config) => {
config.module.rules.push({
test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/,
use: {
loader: "url-loader",
options: {
limit: 100000,
},
},
});
};
config.plugins.push(
new MonacoWebpackPlugin({
languages: ["html", "json", "javascript"],
filename: "static/[name].worker.js",
})
);
return config;
},
});
module.exports = nextConfig;

View file

@ -6,31 +6,38 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next build && next export -o dist"
},
"dependencies": {
"@apollo/client": "^3.2.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"@zeit/next-css": "^1.0.1",
"graphql": "^15.3.0",
"monaco-editor": "^0.20.0",
"monaco-editor-webpack-plugin": "^1.9.0",
"next": "^9.5.4",
"@emotion/react": "^11.7.1",
"@emotion/server": "^11.4.0",
"@emotion/styled": "^11.6.0",
"@monaco-editor/react": "^4.3.1",
"@mui/icons-material": "^5.3.1",
"@mui/lab": "^5.0.0-alpha.66",
"@mui/material": "^5.3.1",
"deepmerge": "^4.2.2",
"graphql": "^16.2.0",
"lodash": "^4.17.21",
"monaco-editor": "^0.31.1",
"next": "^12.0.8",
"next-fonts": "^1.0.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-monaco-editor": "^0.34.0",
"react-syntax-highlighter": "^13.5.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.0.3"
},
"devDependencies": {
"@types/node": "^14.11.1",
"@types/react": "^16.9.49",
"eslint": "^7.9.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.4",
"prettier": "^2.1.2"
"@babel/core": "^7.0.0",
"@types/lodash": "^4.14.178",
"@types/node": "^17.0.12",
"@types/react": "^17.0.38",
"eslint": "^8.7.0",
"eslint-config-next": "12.0.8",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.1.2",
"webpack": "^5.67.0"
}
}

View file

@ -1,10 +1,6 @@
import { Paper } from "@material-ui/core";
import { Paper } from "@mui/material";
function CenteredPaper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<div>
<Paper

View file

@ -1,31 +1,31 @@
import React from "react";
import {
makeStyles,
Theme,
createStyles,
useTheme,
AppBar,
Toolbar,
IconButton,
Typography,
Drawer,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
} from "@material-ui/core";
styled,
CSSObject,
Box,
ListItemText,
} from "@mui/material";
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
import MuiDrawer from "@mui/material/Drawer";
import MuiListItemButton, { ListItemButtonProps } from "@mui/material/ListItemButton";
import MuiListItemIcon, { ListItemIconProps } from "@mui/material/ListItemIcon";
import Link from "next/link";
import MenuIcon from "@material-ui/icons/Menu";
import HomeIcon from "@material-ui/icons/Home";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send";
import FolderIcon from "@material-ui/icons/Folder";
import LocationSearchingIcon from "@material-ui/icons/LocationSearching";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import clsx from "clsx";
import MenuIcon from "@mui/icons-material/Menu";
import HomeIcon from "@mui/icons-material/Home";
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import SendIcon from "@mui/icons-material/Send";
import FolderIcon from "@mui/icons-material/Folder";
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
export enum Page {
Home,
@ -39,85 +39,91 @@ export enum Page {
const drawerWidth = 240;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
const openedMixin = (theme: Theme): CSSObject => ({
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: "hidden",
});
const closedMixin = (theme: Theme): CSSObject => ({
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: "hidden",
width: 56,
});
const DrawerHeader = styled("div")(({ theme }) => ({
display: "flex",
width: "100%",
},
appBar: {
alignItems: "center",
justifyContent: "flex-start",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
}));
interface AppBarProps extends MuiAppBarProps {
open?: boolean;
}
const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== "open",
})<AppBarProps>(({ theme, open }) => ({
backgroundColor: theme.palette.secondary.dark,
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
...(open && {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
menuButton: {
marginRight: 28,
},
hide: {
display: "none",
},
drawer: {
}),
}));
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== "open" })(({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: "nowrap",
},
drawerOpen: {
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
boxSizing: "border-box",
...(open && {
...openedMixin(theme),
"& .MuiDrawer-paper": openedMixin(theme),
}),
},
drawerClose: {
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
...(!open && {
...closedMixin(theme),
"& .MuiDrawer-paper": closedMixin(theme),
}),
overflowX: "hidden",
width: theme.spacing(7) + 1,
}));
const ListItemButton = styled(MuiListItemButton)<ListItemButtonProps>(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
width: theme.spacing(7) + 8,
px: 1,
},
"&.MuiListItemButton-root": {
"&.Mui-selected": {
backgroundColor: theme.palette.primary.main,
"& .MuiListItemIcon-root": {
color: theme.palette.secondary.dark,
},
"& .MuiListItemText-root": {
color: theme.palette.secondary.dark,
},
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
},
content: {
flexGrow: 1,
padding: theme.spacing(3),
},
listItem: {
paddingLeft: 16,
paddingRight: 16,
[theme.breakpoints.up("sm")]: {
paddingLeft: 20,
paddingRight: 20,
},
},
listItemIcon: {
}));
const ListItemIcon = styled(MuiListItemIcon)<ListItemIconProps>(() => ({
minWidth: 42,
},
titleHighlight: {
color: theme.palette.secondary.main,
marginRight: 4,
},
})
);
}));
interface Props {
children: React.ReactNode;
@ -126,7 +132,6 @@ interface Props {
}
export function Layout({ title, page, children }: Props): JSX.Element {
const classes = useStyles();
const theme = useTheme();
const [open, setOpen] = React.useState(false);
@ -138,145 +143,109 @@ export function Layout({ title, page, children }: Props): JSX.Element {
setOpen(false);
};
const SiteTitle = styled("span")({
...(title !== "" && {
color: theme.palette.primary.main,
marginRight: 4,
}),
});
return (
<div className={classes.root}>
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Box sx={{ display: "flex" }}>
<AppBar position="fixed" open={open}>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
aria-label="Open drawer"
onClick={handleDrawerOpen}
edge="start"
className={clsx(classes.menuButton, {
[classes.hide]: open,
})}
sx={{
mr: 5,
...(open && { display: "none" }),
}}
>
<MenuIcon />
</IconButton>
<Typography variant="h5" noWrap>
<span className={title !== "" ? classes.titleHighlight : ""}>
Hetty://
</span>
{title}
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
}),
<Box
sx={{
display: "flex",
justifyContent: "space-around",
width: "100%",
}}
>
<div className={classes.toolbar}>
<Typography variant="h5" noWrap sx={{ width: "100%" }}>
<SiteTitle>Hetty://</SiteTitle>
{title}
</Typography>
<Box sx={{ flexShrink: 0, pt: 0.75 }}>v{process.env.NEXT_PUBLIC_VERSION || "0.0"}</Box>
</Box>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
{theme.direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</IconButton>
</div>
</DrawerHeader>
<Divider />
<List>
<List sx={{ p: 0 }}>
<Link href="/" passHref>
<ListItem
button
component="a"
key="home"
selected={page === Page.Home}
className={classes.listItem}
>
<ListItemButton key="home" selected={page === Page.Home}>
<Tooltip title="Home">
<ListItemIcon className={classes.listItemIcon}>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Home" />
</ListItem>
</ListItemButton>
</Link>
<Link href="/proxy/logs" passHref>
<ListItem
button
component="a"
key="proxyLogs"
selected={page === Page.ProxyLogs}
className={classes.listItem}
>
<ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
<Tooltip title="Proxy">
<ListItemIcon className={classes.listItemIcon}>
<ListItemIcon>
<SettingsEthernetIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Proxy" />
</ListItem>
</ListItemButton>
</Link>
<Link href="/sender" passHref>
<ListItem
button
component="a"
key="sender"
selected={page === Page.Sender}
className={classes.listItem}
>
<ListItemButton key="sender" selected={page === Page.Sender}>
<Tooltip title="Sender">
<ListItemIcon className={classes.listItemIcon}>
<ListItemIcon>
<SendIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Sender" />
</ListItem>
</ListItemButton>
</Link>
<Link href="/scope" passHref>
<ListItem
button
component="a"
key="scope"
selected={page === Page.Scope}
className={classes.listItem}
>
<ListItemButton key="scope" selected={page === Page.Scope}>
<Tooltip title="Scope">
<ListItemIcon className={classes.listItemIcon}>
<ListItemIcon>
<LocationSearchingIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Scope" />
</ListItem>
</ListItemButton>
</Link>
<Link href="/projects" passHref>
<ListItem
button
component="a"
key="projects"
selected={page === Page.Projects}
className={classes.listItem}
>
<ListItemButton key="projects" selected={page === Page.Projects}>
<Tooltip title="Projects">
<ListItemIcon className={classes.listItemIcon}>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Projects" />
</ListItem>
</ListItemButton>
</Link>
</List>
</Drawer>
<main className={classes.content}>
<div className={classes.toolbar} />
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<DrawerHeader />
{children}
</main>
</div>
</Box>
</Box>
);
}

View file

@ -1,29 +1,8 @@
import { gql, useMutation } from "@apollo/client";
import {
Box,
Button,
CircularProgress,
createStyles,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
})
);
const CREATE_PROJECT = gql`
mutation CreateProject($name: String!) {
createProject(name: $name) {
@ -44,21 +23,17 @@ const OPEN_PROJECT = gql`
`;
function NewProject(): JSX.Element {
const classes = useStyles();
const [input, setInput] = useState(null);
const [name, setName] = useState("");
const [createProject, { error: createProjErr, loading: createProjLoading }] = useMutation(CREATE_PROJECT, {
onError: () => {},
onCompleted(data) {
input.value = "";
setName("");
openProject({ variables: { id: data.createProject.id } });
},
});
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted() {
input.value = "";
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
@ -99,7 +74,7 @@ function NewProject(): JSX.Element {
const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
createProject({ variables: { name: input.value } });
createProject({ variables: { name } });
};
return (
@ -109,25 +84,26 @@ function NewProject(): JSX.Element {
</Box>
<form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
sx={{
mr: 2,
}}
color="primary"
size="small"
label="Project name"
placeholder="Project name…"
onChange={(e) => setName(e.target.value)}
error={Boolean(createProjErr || openProjErr)}
helperText={createProjErr && createProjErr.message || openProjErr && openProjErr.message}
helperText={(createProjErr && createProjErr.message) || (openProjErr && openProjErr.message)}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
color="primary"
size="large"
sx={{
pt: 0.9,
pb: 0.7,
}}
disabled={createProjLoading || openProjLoading}
startIcon={createProjLoading || openProjLoading ? <CircularProgress size={22} /> : <AddIcon />}
>

View file

@ -4,7 +4,6 @@ import {
Box,
Button,
CircularProgress,
createStyles,
Dialog,
DialogActions,
DialogContent,
@ -16,34 +15,20 @@ import {
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
makeStyles,
Paper,
Snackbar,
Theme,
Tooltip,
Typography,
} from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import DescriptionIcon from "@material-ui/icons/Description";
import DeleteIcon from "@material-ui/icons/Delete";
import LaunchIcon from "@material-ui/icons/Launch";
import { Alert } from "@material-ui/lab";
useTheme,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import DescriptionIcon from "@mui/icons-material/Description";
import DeleteIcon from "@mui/icons-material/Delete";
import LaunchIcon from "@mui/icons-material/Launch";
import { Alert } from "@mui/lab";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectsList: {
backgroundColor: theme.palette.background.paper,
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
deleteProjectButton: {
color: theme.palette.error.main,
},
})
);
import { Project } from "../../lib/Project";
const PROJECTS = gql`
query Projects {
@ -82,22 +67,19 @@ const DELETE_PROJECT = gql`
`;
function ProjectList(): JSX.Element {
const classes = useStyles();
const { loading: projLoading, error: projErr, data: projData } = useQuery(
PROJECTS
);
const [
openProject,
{ error: openProjErr, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
const theme = useTheme();
const { loading: projLoading, error: projErr, data: projData } = useQuery<{ projects: Project[] }>(PROJECTS);
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation<{ openProject: Project }>(
OPEN_PROJECT,
{
errorPolicy: "all",
onError: () => {},
update(cache, { data: { openProject } }) {
update(cache, { data }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: openProject,
data: data?.openProject,
fragment: gql`
fragment ActiveProject on Project {
id
@ -111,7 +93,7 @@ function ProjectList(): JSX.Element {
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.id,
id: data?.openProject.id,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
@ -130,7 +112,8 @@ function ProjectList(): JSX.Element {
},
});
},
});
}
);
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
errorPolicy: "all",
onError: () => {},
@ -150,10 +133,7 @@ function ProjectList(): JSX.Element {
});
},
});
const [
deleteProject,
{ loading: deleteProjLoading, error: deleteProjErr },
] = useMutation(DELETE_PROJECT, {
const [deleteProject, { loading: deleteProjLoading, error: deleteProjErr }] = useMutation(DELETE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
@ -169,21 +149,21 @@ function ProjectList(): JSX.Element {
},
});
const [deleteProj, setDeleteProj] = useState(null);
const [deleteProj, setDeleteProj] = useState<Project>();
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (project: any) => {
setDeleteProj(project);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
deleteProject({ variables: { id: deleteProj.id } });
deleteProject({ variables: { id: deleteProj?.id } });
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
};
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_, reason?: string) => {
const handleCloseDeleteNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
@ -198,23 +178,25 @@ function ProjectList(): JSX.Element {
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a project permanently removes its database file from disk.
This action is irreversible.
Deleting a project permanently removes all its data from the database. This action is irreversible.
</DialogContentText>
{deleteProjErr && (
<Alert severity="error">
Error closing project: {deleteProjErr.message}
</Alert>
)}
{deleteProjErr && <Alert severity="error">Error closing project: {deleteProjErr.message}</Alert>}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus>
<Button onClick={handleDeleteCancel} autoFocus color="secondary" variant="contained">
Cancel
</Button>
<Button
className={classes.deleteProjectButton}
sx={{
color: "white",
backgroundColor: "error.main",
"&:hover": {
backgroundColor: "error.dark",
},
}}
onClick={handleDeleteConfirm}
disabled={deleteProjLoading}
variant="contained"
>
Delete
</Button>
@ -225,6 +207,7 @@ function ProjectList(): JSX.Element {
open={deleteNotifOpen}
autoHideDuration={3000}
onClose={handleCloseDeleteNotif}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
>
<Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProj?.name}</strong> was deleted.
@ -237,32 +220,24 @@ function ProjectList(): JSX.Element {
<Box mb={4}>
{projLoading && <CircularProgress />}
{projErr && (
<Alert severity="error">
Error fetching projects: {projErr.message}
</Alert>
)}
{openProjErr && (
<Alert severity="error">
Error opening project: {openProjErr.message}
</Alert>
)}
{closeProjErr && (
<Alert severity="error">
Error closing project: {closeProjErr.message}
</Alert>
)}
{projErr && <Alert severity="error">Error fetching projects: {projErr.message}</Alert>}
{openProjErr && <Alert severity="error">Error opening project: {openProjErr.message}</Alert>}
{closeProjErr && <Alert severity="error">Error closing project: {closeProjErr.message}</Alert>}
</Box>
{projData?.projects.length > 0 && (
<List className={classes.projectsList}>
{projData && projData.projects.length > 0 && (
<Paper>
<List>
{projData.projects.map((project) => (
<ListItem key={project.id}>
<ListItemAvatar>
<Avatar
className={
project.isActive ? classes.activeProject : undefined
}
sx={{
...(project.isActive && {
color: theme.palette.secondary.dark,
backgroundColor: theme.palette.primary.main,
}),
}}
>
<DescriptionIcon />
</Avatar>
@ -296,10 +271,7 @@ function ProjectList(): JSX.Element {
)}
<Tooltip title="Delete project">
<span>
<IconButton
onClick={() => handleDeleteButtonClick(project)}
disabled={project.isActive}
>
<IconButton onClick={() => handleDeleteButtonClick(project)} disabled={project.isActive}>
<DeleteIcon />
</IconButton>
</span>
@ -308,11 +280,10 @@ function ProjectList(): JSX.Element {
</ListItem>
))}
</List>
</Paper>
)}
{projData?.projects.length === 0 && (
<Alert severity="info">
There are no projects. Create one to get started.
</Alert>
<Alert severity="info">There are no projects. Create one to get started.</Alert>
)}
</div>
);

View file

@ -1,10 +1,10 @@
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
export function useConfirmationDialog() {
const [isOpen, setIsOpen] = useState(false);
@ -38,12 +38,10 @@ export function ConfirmationDialog(props: ConfirmationDialog) {
>
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{children}
</DialogContentText>
<DialogContentText id="alert-dialog-description">{children}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abort</Button>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={confirm} autoFocus>
Confirm
</Button>

View file

@ -1,7 +1,7 @@
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(import("react-monaco-editor"), { ssr: false });
import MonacoEditor from "@monaco-editor/react";
import monaco from "monaco-editor/esm/vs/editor/editor.api";
const monacoOptions = {
const monacoOptions: monaco.editor.IEditorOptions = {
readOnly: true,
wordWrap: "on",
minimap: {
@ -11,20 +11,7 @@ const monacoOptions = {
type language = "html" | "typescript" | "json";
function editorDidMount() {
return ((window as any).MonacoEnvironment.getWorkerUrl = (
moduleId,
label
) => {
if (label === "json") return "/_next/static/json.worker.js";
if (label === "html") return "/_next/static/html.worker.js";
if (label === "javascript") return "/_next/static/ts.worker.js";
return "/_next/static/editor.worker.js";
});
}
function languageForContentType(contentType: string): language {
function languageForContentType(contentType?: string): language | undefined {
switch (contentType) {
case "text/html":
return "html";
@ -41,7 +28,7 @@ function languageForContentType(contentType: string): language {
interface Props {
content: string;
contentType: string;
contentType?: string;
}
function Editor({ content, contentType }: Props): JSX.Element {
@ -50,8 +37,7 @@ function Editor({ content, contentType }: Props): JSX.Element {
height={"600px"}
language={languageForContentType(contentType)}
theme="vs-dark"
editorDidMount={editorDidMount}
options={monacoOptions as any}
options={monacoOptions}
value={content}
/>
);

View file

@ -1,83 +1,66 @@
import {
makeStyles,
Theme,
createStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Snackbar,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar } from "@mui/material";
import { Alert } from "@mui/lab";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) => {
const paddingX = 0;
const paddingY = theme.spacing(1) / 3;
const tableCell = {
paddingLeft: paddingX,
paddingRight: paddingX,
paddingTop: paddingY,
paddingBottom: paddingY,
const baseCellStyle = {
px: 0,
py: 0.33,
verticalAlign: "top",
border: "none",
whiteSpace: "nowrap" as any,
overflow: "hidden",
textOverflow: "ellipsis",
"&:hover": {
color: theme.palette.secondary.main,
color: "primary.main",
whiteSpace: "inherit" as any,
overflow: "inherit",
textOverflow: "inherit",
cursor: "copy",
},
};
return createStyles({
root: {},
table: {
tableLayout: "fixed",
width: "100%",
},
keyCell: {
...tableCell,
paddingRight: theme.spacing(1),
const keyCellStyle = {
...baseCellStyle,
pr: 1,
width: "40%",
fontWeight: "bold",
fontSize: ".75rem",
},
valueCell: {
...tableCell,
};
const valueCellStyle = {
...baseCellStyle,
width: "60%",
border: "none",
fontSize: ".75rem",
},
});
});
};
interface Props {
headers: Array<{ key: string; value: string }>;
}
function HttpHeadersTable({ headers }: Props): JSX.Element {
const classes = useStyles();
const [open, setOpen] = useState(false);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const windowSel = window.getSelection();
if (!windowSel || !document) {
return;
}
const r = document.createRange();
r.selectNode(e.currentTarget);
window.getSelection().removeAllRanges();
window.getSelection().addRange(r);
windowSel.removeAllRanges();
windowSel.addRange(r);
document.execCommand("copy");
window.getSelection().removeAllRanges();
windowSel.removeAllRanges();
setOpen(true);
};
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
@ -92,20 +75,21 @@ function HttpHeadersTable({ headers }: Props): JSX.Element {
Copied to clipboard.
</Alert>
</Snackbar>
<TableContainer className={classes.root}>
<Table className={classes.table} size="small">
<TableContainer>
<Table
sx={{
tableLayout: "fixed",
width: "100%",
}}
size="small"
>
<TableBody>
{headers.map(({ key, value }, index) => (
<TableRow key={index}>
<TableCell
component="th"
scope="row"
className={classes.keyCell}
onClick={handleClick}
>
<TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
<code>{key}:</code>
</TableCell>
<TableCell className={classes.valueCell} onClick={handleClick}>
<TableCell sx={valueCellStyle} onClick={handleClick}>
<code>{value}</code>
</TableCell>
</TableRow>

View file

@ -1,31 +1,25 @@
import { Theme, withTheme } from "@material-ui/core";
import { orange, red } from "@material-ui/core/colors";
import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord";
import { SvgIconTypeMap } from "@mui/material";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
interface Props {
status: number;
theme: Theme;
}
function HttpStatusIcon({ status, theme }: Props): JSX.Element {
const style = { marginTop: "-.25rem", verticalAlign: "middle" };
export default function HttpStatusIcon({ status }: Props): JSX.Element {
let color: SvgIconTypeMap["props"]["color"] = "inherit";
switch (Math.floor(status / 100)) {
case 2:
case 3:
return (
<FiberManualRecordIcon
style={{ ...style, color: theme.palette.secondary.main }}
/>
);
color = "primary";
break;
case 4:
return (
<FiberManualRecordIcon style={{ ...style, color: orange["A400"] }} />
);
color = "warning";
break;
case 5:
return <FiberManualRecordIcon style={{ ...style, color: red["A400"] }} />;
default:
return <FiberManualRecordIcon style={style} />;
}
color = "error";
break;
}
export default withTheme(HttpStatusIcon);
return <FiberManualRecordIcon sx={{ marginTop: "-.25rem", verticalAlign: "middle" }} color={color} />;
}

View file

@ -1,9 +1,9 @@
import { gql, useQuery } from "@apollo/client";
import { Box, Grid, Paper, CircularProgress } from "@material-ui/core";
import { Box, Grid, Paper, CircularProgress } from "@mui/material";
import ResponseDetail from "./ResponseDetail";
import RequestDetail from "./RequestDetail";
import Alert from "@material-ui/lab/Alert";
import Alert from "@mui/lab/Alert";
const HTTP_REQUEST_LOG = gql`
query HttpRequestLog($id: ID!) {
@ -44,11 +44,7 @@ function LogDetail({ requestId: id }: Props): JSX.Element {
return <CircularProgress />;
}
if (error) {
return (
<Alert severity="error">
Error fetching logs details: {error.message}
</Alert>
);
return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
}
if (!data.httpRequestLog) {

View file

@ -1,12 +1,7 @@
import { useRouter } from "next/router";
import Link from "next/link";
import {
Box,
CircularProgress,
Link as MaterialLink,
Typography,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
import Alert from "@mui/lab/Alert";
import RequestList from "./RequestList";
import LogDetail from "./LogDetail";
@ -33,7 +28,7 @@ function LogsOverview(): JSX.Element {
<Alert severity="info">
There is no project active.{" "}
<Link href="/projects" passHref>
<MaterialLink color="secondary">Create or open</MaterialLink>
<MaterialLink color="primary">Create or open</MaterialLink>
</Link>{" "}
one first.
</Alert>
@ -47,11 +42,7 @@ function LogsOverview(): JSX.Element {
return (
<div>
<Box mb={2}>
<RequestList
logs={logs}
selectedReqLogId={detailReqLogId}
onLogClick={handleLogClick}
/>
<RequestList logs={logs || []} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
</Box>
<Box>
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}

View file

@ -1,42 +1,9 @@
import React from "react";
import {
Typography,
Box,
createStyles,
makeStyles,
Theme,
Divider,
} from "@material-ui/core";
import { Typography, Box, Divider } from "@mui/material";
import HttpHeadersTable from "./HttpHeadersTable";
import Editor from "./Editor";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
requestTitle: {
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
headersTable: {
tableLayout: "fixed",
width: "100%",
},
headerKeyCell: {
verticalAlign: "top",
width: "30%",
fontWeight: "bold",
},
headerValueCell: {
width: "70%",
verticalAlign: "top",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
})
);
interface Props {
request: {
method: string;
@ -49,29 +16,27 @@ interface Props {
function RequestDetail({ request }: Props): JSX.Element {
const { method, url, proto, headers, body } = request;
const classes = useStyles();
const contentType = headers.find((header) => header.key === "Content-Type")
?.value;
const contentType = headers.find((header) => header.key === "Content-Type")?.value;
const parsedUrl = new URL(url);
return (
<div>
<Box p={2}>
<Typography
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
Request
</Typography>
<Typography className={classes.requestTitle} variant="h6">
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
<Typography
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
sx={{
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
}}
variant="h6"
>
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{proto}
</Typography>
</Typography>

View file

@ -8,48 +8,23 @@ import {
TableBody,
Typography,
Box,
createStyles,
makeStyles,
Theme,
withTheme,
} from "@material-ui/core";
useTheme,
} from "@mui/material";
import HttpStatusIcon from "./HttpStatusCode";
import CenteredPaper from "../CenteredPaper";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
row: {
"&:hover": {
cursor: "pointer",
},
},
/* Pseudo-class applied to the root element if `hover={true}`. */
hover: {},
})
);
import { RequestLog } from "../../lib/requestLogs";
interface Props {
logs: Array<any>;
logs: RequestLog[];
selectedReqLogId?: string;
onLogClick(requestId: string): void;
theme: Theme;
}
function RequestList({
logs,
onLogClick,
selectedReqLogId,
theme,
}: Props): JSX.Element {
export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
return (
<div>
<RequestListTable
onLogClick={onLogClick}
logs={logs}
selectedReqLogId={selectedReqLogId}
theme={theme}
/>
<RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
{logs.length === 0 && (
<Box my={1}>
<CenteredPaper>
@ -62,19 +37,14 @@ function RequestList({
}
interface RequestListTableProps {
logs?: any;
logs: RequestLog[];
selectedReqLogId?: string;
onLogClick(requestId: string): void;
theme: Theme;
}
function RequestListTable({
logs,
selectedReqLogId,
onLogClick,
theme,
}: RequestListTableProps): JSX.Element {
const classes = useStyles();
function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
const theme = useTheme();
return (
<TableContainer
component={Paper}
@ -102,26 +72,25 @@ function RequestListTable({
textOverflow: "ellipsis",
} as any;
const rowStyle = {
backgroundColor:
id === selectedReqLogId && theme.palette.action.selected,
};
return (
<TableRow
key={id}
className={classes.row}
style={rowStyle}
sx={{
"&:hover": {
cursor: "pointer",
},
...(id === selectedReqLogId && {
bgcolor: theme.palette.action.selected,
}),
}}
hover
onClick={() => onLogClick(id)}
>
<TableCell style={{ ...cellStyle, width: "100px" }}>
<code>{method}</code>
</TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "100px" }}>
{origin}
</TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "200px" }}>
<TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
<TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
{decodeURIComponent(pathname + search + hash)}
</TableCell>
<TableCell style={{ maxWidth: "100px" }}>
@ -142,5 +111,3 @@ function RequestListTable({
</TableContainer>
);
}
export default withTheme(RequestList);

View file

@ -1,4 +1,4 @@
import { Typography, Box, Divider } from "@material-ui/core";
import { Typography, Box, Divider } from "@mui/material";
import HttpStatusIcon from "./HttpStatusCode";
import Editor from "./Editor";
@ -15,30 +15,17 @@ interface Props {
}
function ResponseDetail({ response }: Props): JSX.Element {
const contentType = response.headers.find(
(header) => header.key === "Content-Type"
)?.value;
const contentType = response.headers.find((header) => header.key === "Content-Type")?.value;
return (
<div>
<Box p={2}>
<Typography
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
Response
</Typography>
<Typography
variant="h6"
style={{ fontSize: "1rem", whiteSpace: "nowrap" }}
>
<Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
<HttpStatusIcon status={response.statusCode} />{" "}
<Typography component="span" color="textSecondary">
<Typography
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{response.proto}
</Typography>
</Typography>{" "}
@ -52,9 +39,7 @@ function ResponseDetail({ response }: Props): JSX.Element {
<HttpHeadersTable headers={response.headers} />
</Box>
{response.body && (
<Editor content={response.body} contentType={contentType} />
)}
{response.body && <Editor content={response.body} contentType={contentType} />}
</div>
);
}

View file

@ -3,29 +3,23 @@ import {
Checkbox,
CircularProgress,
ClickAwayListener,
createStyles,
FormControlLabel,
InputBase,
makeStyles,
Paper,
Popper,
Theme,
Tooltip,
useTheme,
} from "@material-ui/core";
import IconButton from "@material-ui/core/IconButton";
import SearchIcon from "@material-ui/icons/Search";
import FilterListIcon from "@material-ui/icons/FilterList";
import DeleteIcon from "@material-ui/icons/Delete";
} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import SearchIcon from "@mui/icons-material/Search";
import FilterListIcon from "@mui/icons-material/FilterList";
import DeleteIcon from "@mui/icons-material/Delete";
import React, { useRef, useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { withoutTypename } from "../../lib/omitTypename";
import { Alert } from "@material-ui/lab";
import { Alert } from "@mui/lab";
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
import {
ConfirmationDialog,
useConfirmationDialog,
} from "./ConfirmationDialog";
import { ConfirmationDialog, useConfirmationDialog } from "./ConfirmationDialog";
const FILTER = gql`
query HttpRequestLogFilter {
@ -45,79 +39,43 @@ const SET_FILTER = gql`
}
`;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
},
input: {
marginLeft: theme.spacing(1),
flex: 1,
},
iconButton: {
padding: 10,
},
filterPopper: {
width: 400,
marginTop: 6,
zIndex: 99,
},
filterOptions: {
padding: theme.spacing(2),
},
filterLoading: {
marginRight: 1,
color: theme.palette.text.primary,
},
})
);
export interface SearchFilter {
onlyInScope: boolean;
searchExpression: string;
}
function Search(): JSX.Element {
const classes = useStyles();
const theme = useTheme();
const [searchExpr, setSearchExpr] = useState("");
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
FILTER,
{
const {
loading: filterLoading,
error: filterErr,
data: filter,
} = useQuery(FILTER, {
onCompleted: (data) => {
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
},
}
);
});
const [
setFilterMutate,
{ error: setFilterErr, loading: setFilterLoading },
] = useMutation<{
const [setFilterMutate, { error: setFilterErr, loading: setFilterLoading }] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
update(cache, { data: { setHttpRequestLogFilter } }) {
update(cache, { data }) {
cache.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: setHttpRequestLogFilter,
httpRequestLogFilter: data?.setHttpRequestLogFilter,
},
});
},
onError: () => {},
});
const [
clearHTTPRequestLog,
clearHTTPRequestLogResult,
] = useClearHTTPRequestLog();
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHTTPRequestLog();
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLElement | null>();
const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => {
@ -133,8 +91,8 @@ function Search(): JSX.Element {
e.preventDefault();
};
const handleClickAway = (event: React.MouseEvent<EventTarget>) => {
if (filterRef.current.contains(event.target as HTMLElement)) {
const handleClickAway = (event: MouseEvent | TouchEvent) => {
if (filterRef?.current && filterRef.current.contains(event.target as HTMLElement)) {
return;
}
setFilterOpen(false);
@ -144,63 +102,67 @@ function Search(): JSX.Element {
<Box>
<Error prefix="Error fetching filter" error={filterErr} />
<Error prefix="Error setting filter" error={setFilterErr} />
<Error
prefix="Error clearing all HTTP logs"
error={clearHTTPRequestLogResult.error}
/>
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
onSubmit={handleSubmit}
ref={filterRef}
className={classes.root}
sx={{
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
}}
>
<Tooltip title="Toggle filter options">
<IconButton
className={classes.iconButton}
onClick={() => setFilterOpen(!filterOpen)}
style={{
color: filter?.httpRequestLogFilter?.onlyInScope
? theme.palette.secondary.main
: "inherit",
sx={{
p: 1,
color: filter?.httpRequestLogFilter?.onlyInScope ? "primary.main" : "inherit",
}}
>
{filterLoading || setFilterLoading ? (
<CircularProgress
className={classes.filterLoading}
size={23}
/>
<CircularProgress sx={{ color: theme.palette.text.primary }} size={23} />
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
className={classes.input}
sx={{
ml: 1,
flex: 1,
}}
placeholder="Search proxy logs…"
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" className={classes.iconButton}>
<IconButton type="submit" sx={{ padding: 1.25 }}>
<SearchIcon />
</IconButton>
</Tooltip>
<Popper
className={classes.filterPopper}
open={filterOpen}
anchorEl={filterRef.current}
placement="bottom-start"
placement="bottom"
style={{ zIndex: theme.zIndex.appBar }}
>
<Paper
sx={{
width: 400,
marginTop: 0.5,
p: 1.5,
}}
>
<Paper className={classes.filterOptions}>
<FormControlLabel
control={
<Checkbox
checked={
filter?.httpRequestLogFilter?.onlyInScope ? true : false
}
checked={filter?.httpRequestLogFilter?.onlyInScope ? true : false}
disabled={filterLoading || setFilterLoading}
onChange={(e) =>
setFilterMutate({

View file

@ -3,20 +3,18 @@ import {
Box,
Button,
CircularProgress,
createStyles,
FormControl,
FormControlLabel,
FormLabel,
makeStyles,
Radio,
RadioGroup,
TextField,
Theme,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import { Alert } from "@material-ui/lab";
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { Alert } from "@mui/lab";
import React from "react";
import { SCOPE } from "./Rules";
import { ScopeRule } from "../../lib/scope";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
@ -26,25 +24,15 @@ const SET_SCOPE = gql`
}
`;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
ruleExpression: {
fontFamily: "'JetBrains Mono', monospace",
},
})
);
function AddRule(): JSX.Element {
const classes = useStyles();
const [ruleType, setRuleType] = React.useState("url");
const [expression, setExpression] = React.useState(null);
const [expression, setExpression] = React.useState("");
const client = useApolloClient();
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
onError() {},
onCompleted() {
expression.value = "";
setExpression("");
},
update(_, { data: { setScope } }) {
client.writeQuery({
@ -59,21 +47,20 @@ function AddRule(): JSX.Element {
};
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
let scope = [];
let scope: ScopeRule[] = [];
try {
const data = client.readQuery({
const data = client.readQuery<{ scope: ScopeRule[] }>({
query: SCOPE,
});
if (data) {
scope = data.scope;
}
} catch (e) {}
setScope({
variables: {
scope: [
...scope.map(({ url }) => ({ url })),
{ url: expression.value },
],
scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
},
});
};
@ -87,15 +74,10 @@ function AddRule(): JSX.Element {
)}
<form onSubmit={handleSubmit} autoComplete="off">
<FormControl fullWidth>
<FormLabel color="secondary" component="legend">
<FormLabel color="primary" component="legend">
Rule Type
</FormLabel>
<RadioGroup
row
name="ruleType"
value={ruleType}
onChange={handleTypeChange}
>
<RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
<FormControlLabel value="url" control={<Radio />} label="URL" />
</RadioGroup>
</FormControl>
@ -104,20 +86,17 @@ function AddRule(): JSX.Element {
label="Expression"
placeholder="^https:\/\/(.*)example.com(.*)"
helperText="Regular expression to match on."
color="secondary"
color="primary"
variant="outlined"
required
value={expression}
onChange={(e) => setExpression(e.target.value)}
InputProps={{
className: classes.ruleExpression,
sx: { fontFamily: "'JetBrains Mono', monospace" },
}}
InputLabelProps={{
shrink: true,
}}
inputProps={{
ref: (node) => {
setExpression(node);
},
}}
margin="normal"
/>
</FormControl>
@ -125,7 +104,7 @@ function AddRule(): JSX.Element {
<Button
type="submit"
variant="contained"
color="secondary"
color="primary"
disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
>

View file

@ -8,11 +8,12 @@ import {
ListItemSecondaryAction,
ListItemText,
Tooltip,
} from "@material-ui/core";
import CodeIcon from "@material-ui/icons/Code";
import DeleteIcon from "@material-ui/icons/Delete";
} from "@mui/material";
import CodeIcon from "@mui/icons-material/Code";
import DeleteIcon from "@mui/icons-material/Delete";
import React from "react";
import { SCOPE } from "./Rules";
import { ScopeRule } from "../../lib/scope";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
@ -22,7 +23,13 @@ const SET_SCOPE = gql`
}
`;
function RuleListItem({ scope, rule, index }): JSX.Element {
type RuleListItemProps = {
scope: ScopeRule[];
rule: ScopeRule;
index: number;
};
function RuleListItem({ scope, rule, index }: RuleListItemProps): JSX.Element {
const client = useApolloClient();
const [setScope, { loading }] = useMutation(SET_SCOPE, {
update(_, { data: { setScope } }) {
@ -65,8 +72,8 @@ function RuleListItem({ scope, rule, index }): JSX.Element {
);
}
function RuleListItemText({ rule }): JSX.Element {
let text: JSX.Element;
function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
let text: JSX.Element = <div></div>;
if (rule.url) {
text = <code>{rule.url}</code>;
@ -77,10 +84,14 @@ function RuleListItemText({ rule }): JSX.Element {
return <ListItemText>{text}</ListItemText>;
}
function RuleTypeChip({ rule }): JSX.Element {
function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
let label = "Unknown";
if (rule.url) {
return <Chip label="URL" variant="outlined" />;
label = "URL";
}
return <Chip label={label} variant="outlined" />;
}
export default RuleListItem;

View file

@ -1,22 +1,9 @@
import { gql, useQuery } from "@apollo/client";
import {
CircularProgress,
createStyles,
List,
makeStyles,
Theme,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { CircularProgress, List } from "@mui/material";
import { Alert } from "@mui/lab";
import React from "react";
import RuleListItem from "./RuleListItem";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
rulesList: {
backgroundColor: theme.palette.background.paper,
},
})
);
import { ScopeRule } from "../../lib/scope";
export const SCOPE = gql`
query Scope {
@ -27,24 +14,20 @@ export const SCOPE = gql`
`;
function Rules(): JSX.Element {
const classes = useStyles();
const { loading, error, data } = useQuery(SCOPE);
const { loading, error, data } = useQuery<{ scope: ScopeRule[] }>(SCOPE);
return (
<div>
{loading && <CircularProgress />}
{error && (
<Alert severity="error">Error fetching scope: {error.message}</Alert>
)}
{data?.scope.length > 0 && (
<List className={classes.rulesList}>
{error && <Alert severity="error">Error fetching scope: {error.message}</Alert>}
{data && data.scope.length > 0 && (
<List
sx={{
bgcolor: "background.paper",
}}
>
{data.scope.map((rule, index) => (
<RuleListItem
key={index}
rule={rule}
scope={data.scope}
index={index}
/>
<RuleListItem key={index} rule={rule} scope={data.scope} index={index} />
))}
</List>
)}

5
admin/src/lib/Project.ts Normal file
View file

@ -0,0 +1,5 @@
export type Project = {
id: string
name: string
isActive: boolean
}

View file

@ -0,0 +1,7 @@
import createCache from "@emotion/cache";
// prepend: true moves MUI styles to the top of the <head> so they're loaded first.
// It allows developers to easily override MUI styles with other styling solutions, like CSS modules.
export default function createEmotionCache() {
return createCache({ key: "css", prepend: true });
}

View file

@ -1,7 +1,11 @@
import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
import merge from "deepmerge";
import isEqual from "lodash/isEqual";
let apolloClient;
export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";
let apolloClient: ApolloClient<NormalizedCacheObject>;
function createApolloClient() {
return new ApolloClient({
@ -21,9 +25,18 @@ export function initializeApollo(initialState = null) {
if (initialState) {
// Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract();
// Restore the cache using the data passed from getStaticProps/getServerSideProps
// combined with the existing cached data
_apolloClient.cache.restore({ ...existingCache, ...initialState });
// Merge the existing cache into data passed from getStaticProps/getServerSideProps
const data = merge(initialState, existingCache, {
// combine arrays using object equality (like in sets)
arrayMerge: (destinationArray, sourceArray) => [
...sourceArray,
...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
],
});
// Restore the cache with the merged data
_apolloClient.cache.restore(data);
}
// For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient;
@ -33,7 +46,16 @@ export function initializeApollo(initialState = null) {
return _apolloClient;
}
export function useApollo(initialState) {
const store = useMemo(() => initializeApollo(initialState), [initialState]);
export function addApolloState(client: ApolloClient<NormalizedCacheObject>, pageProps: any) {
if (pageProps?.props) {
pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
}
return pageProps;
}
export function useApollo(pageProps: any) {
const state = pageProps[APOLLO_STATE_PROP_NAME];
const store = useMemo(() => initializeApollo(state), [state]);
return store;
}

View file

@ -1,4 +1,4 @@
const omitTypename = (key, value) => (key === "__typename" ? undefined : value);
const omitTypename = (key: string, value: any) => (key === "__typename" ? undefined : value);
export function withoutTypename(input: any): any {
return JSON.parse(JSON.stringify(input), omitTypename);

View file

@ -0,0 +1,24 @@
export type RequestLog = {
id: string
url: string
method: string
proto: string
headers: HTTPHeader[]
body?: string
timestamp: string
response?: ResponseLog
}
export type ResponseLog = {
proto: string
statusCode: number
statusReason: string
body?: string
headers: HTTPHeader[]
}
export type HTTPHeader = {
key: string
value: string
}

3
admin/src/lib/scope.ts Normal file
View file

@ -0,0 +1,3 @@
export type ScopeRule = {
url?: string
}

View file

@ -1,49 +1,51 @@
import { createMuiTheme } from "@material-ui/core/styles";
import grey from "@material-ui/core/colors/grey";
import teal from "@material-ui/core/colors/teal";
import { createTheme } from "@mui/material/styles";
import * as colors from "@mui/material/colors";
const theme = createMuiTheme({
const heading = {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
};
let theme = createTheme({
palette: {
type: "dark",
mode: "dark",
primary: {
main: grey[900],
main: colors.teal["A400"],
},
secondary: {
main: teal["A400"],
},
info: {
main: teal["A400"],
},
success: {
main: teal["A400"],
main: colors.grey[900],
light: "#333",
dark: colors.common.black,
},
},
typography: {
h2: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
h2: heading,
h3: heading,
h4: heading,
h5: heading,
h6: heading,
},
h3: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
});
theme = createTheme(theme, {
palette: {
background: {
default: theme.palette.secondary.main,
paper: theme.palette.secondary.light,
},
h4: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
info: {
main: theme.palette.primary.main,
},
h5: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h6: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
success: {
main: theme.palette.primary.main,
},
},
overrides: {
components: {
MuiTableCell: {
styleOverrides: {
stickyHeader: {
backgroundColor: grey[900],
backgroundColor: theme.palette.secondary.dark,
},
},
},
},

View file

@ -1,32 +1,31 @@
import React from "react";
import * as React from "react";
import Head from "next/head";
import { AppProps } from "next/app";
import { ApolloProvider } from "@apollo/client";
import Head from "next/head";
import { ThemeProvider } from "@material-ui/core/styles";
import CssBaseline from "@material-ui/core/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import { CacheProvider, EmotionCache } from "@emotion/react";
import createEmotionCache from "../lib/createEmotionCache";
import theme from "../lib/theme";
import { useApollo } from "../lib/graphql";
function App({ Component, pageProps }: AppProps): JSX.Element {
const apolloClient = useApollo(pageProps.initialApolloState);
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
React.useEffect(() => {
// Remove the server-side injected CSS.
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles.parentElement.removeChild(jssStyles);
interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
}
}, []);
export default function MyApp(props: MyAppProps) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
const apolloClient = useApollo(pageProps);
return (
<React.Fragment>
<CacheProvider value={emotionCache}>
<Head>
<title>Hetty://</title>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
<meta name="viewport" content="initial-scale=1, width=device-width" />
</Head>
<ApolloProvider client={apolloClient}>
<ThemeProvider theme={theme}>
@ -34,8 +33,6 @@ function App({ Component, pageProps }: AppProps): JSX.Element {
<Component {...pageProps} />
</ThemeProvider>
</ApolloProvider>
</React.Fragment>
</CacheProvider>
);
}
export default App;

View file

@ -1,7 +1,8 @@
import React from "react";
import * as React from "react";
import Document, { Html, Head, Main, NextScript } from "next/document";
import { ServerStyleSheets } from "@material-ui/core/styles";
import createEmotionServer from "@emotion/server/create-instance";
import createEmotionCache from "../lib/createEmotionCache";
import theme from "../lib/theme";
export default class MyDocument extends Document {
@ -11,14 +12,9 @@ export default class MyDocument extends Document {
<Head>
<meta name="theme-color" content={theme.palette.primary.main} />
<link rel="stylesheet" href="/style.css" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" />
{(this.props as any).emotionStyleTags}
</Head>
<body>
<Main />
@ -30,25 +26,60 @@ export default class MyDocument extends Document {
}
// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG).
// it's compatible with static-site generation (SSG).
MyDocument.getInitialProps = async (ctx) => {
// Render app and page and get the context of the page with collected side effects.
const sheets = new ServerStyleSheets();
// Resolution order
//
// On the server:
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. document.getInitialProps
// 4. app.render
// 5. page.render
// 6. document.render
//
// On the server with error:
// 1. document.getInitialProps
// 2. app.render
// 3. page.render
// 4. document.render
//
// On the client
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. app.render
// 4. page.render
const originalRenderPage = ctx.renderPage;
// You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
// However, be aware that it can have global side effects.
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
enhanceApp: (App: any) =>
function EnhanceApp(props) {
return <App emotionCache={cache} {...props} />;
},
});
const initialProps = await Document.getInitialProps(ctx);
// This is important. It prevents emotion to render invalid HTML.
// See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
const emotionStyles = extractCriticalToChunks(initialProps.html);
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(" ")}`}
key={style.key}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
/>
));
return {
...initialProps,
// Styles fragment is rendered after the app and page rendering finish.
styles: [
...React.Children.toArray(initialProps.styles),
sheets.getStyleElement(),
],
emotionStyleTags,
};
};

View file

@ -1,4 +1,4 @@
import { Box, Link as MaterialLink, Typography } from "@material-ui/core";
import { Box, Link as MaterialLink, Typography } from "@mui/material";
import Link from "next/link";
import React from "react";
@ -13,17 +13,13 @@ function Index(): JSX.Element {
<Typography variant="h4">Get started</Typography>
</Box>
<Typography paragraph>
Youve loaded a (new) project. Whats next? You can now use the MITM
proxy and review HTTP requests and responses via the{" "}
Youve loaded a (new) project. Whats next? You can now use the MITM proxy and review HTTP requests and
responses via the{" "}
<Link href="/proxy/logs" passHref>
<MaterialLink color="secondary">Proxy logs</MaterialLink>
<MaterialLink color="primary">Proxy logs</MaterialLink>
</Link>
. Stuck? Ask for help on the{" "}
<MaterialLink
href="https://github.com/dstotijn/hetty/discussions"
color="secondary"
target="_blank"
>
<MaterialLink href="https://github.com/dstotijn/hetty/discussions" color="primary" target="_blank">
Discussions forum
</MaterialLink>
.

View file

@ -1,61 +1,46 @@
import {
Box,
Button,
createStyles,
makeStyles,
Theme,
Typography,
} from "@material-ui/core";
import FolderIcon from "@material-ui/icons/Folder";
import { Box, Button, Typography } from "@mui/material";
import FolderIcon from "@mui/icons-material/Folder";
import Link from "next/link";
import { useRouter } from "next/router";
import Layout, { Page } from "../components/Layout";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
titleHighlight: {
color: theme.palette.secondary.main,
},
subtitle: {
fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
marginBottom: theme.spacing(5),
},
button: {
marginRight: theme.spacing(2),
},
})
);
function Index(): JSX.Element {
const classes = useStyles();
const highlightSx = { color: "primary.main" };
return (
<Layout page={Page.Home} title="">
<Box p={4}>
<Box mb={4} width="60%">
<Typography variant="h2">
<span className={classes.titleHighlight}>Hetty://</span>
<Box component="span" sx={highlightSx}>
Hetty://
</Box>
<br />
The simple HTTP toolkit for security research.
</Typography>
</Box>
<Typography className={classes.subtitle} paragraph>
What if security testing was intuitive, powerful, and good looking?
What if it was <strong>free</strong>, instead of $400 per year?{" "}
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
<code>:8080</code>
<Typography
paragraph
sx={{
fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
mb: 5,
}}
>
Welcome to{" "}
<Box component="span" sx={highlightSx}>
Hetty
</Box>
. Get started by creating a project.
</Typography>
<Link href="/projects" passHref>
<Button
className={classes.button}
sx={{ mr: 2 }}
variant="contained"
color="secondary"
color="primary"
component="a"
size="large"
startIcon={<FolderIcon />}

View file

@ -1,4 +1,4 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core";
import { Box, Divider, Grid, Typography } from "@mui/material";
import Layout, { Page } from "../../components/Layout";
import NewProject from "../../components/projects/NewProject";
import ProjectList from "../../components/projects/ProjectList";
@ -11,8 +11,7 @@ function Index(): JSX.Element {
<Typography variant="h4">Projects</Typography>
</Box>
<Typography paragraph>
Projects contain settings and data generated/processed by Hetty. They
are stored in a single database on disk.
Projects contain settings and data generated/processed by Hetty. They are stored in a single database on disk.
</Typography>
<Box my={4}>
<Divider />

View file

@ -1,6 +1,6 @@
import React from "react";
import { Button, Typography } from "@material-ui/core";
import ListIcon from "@material-ui/icons/List";
import { Button, Typography } from "@mui/material";
import ListIcon from "@mui/icons-material/List";
import Link from "next/link";
import Layout, { Page } from "../../components/Layout";
@ -10,13 +10,7 @@ function Index(): JSX.Element {
<Layout page={Page.ProxySetup} title="Proxy setup">
<Typography paragraph>Coming soon</Typography>
<Link href="/proxy/logs" passHref>
<Button
variant="contained"
color="secondary"
component="a"
size="large"
startIcon={<ListIcon />}
>
<Button variant="contained" color="primary" component="a" size="large" startIcon={<ListIcon />}>
View logs
</Button>
</Link>

View file

@ -1,4 +1,4 @@
import { Box } from "@material-ui/core";
import { Box } from "@mui/material";
import LogsOverview from "../../../components/reqlog/LogsOverview";
import Layout, { Page } from "../../../components/Layout";

View file

@ -1,4 +1,4 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core";
import { Box, Divider, Grid, Typography } from "@mui/material";
import React from "react";
import Layout, { Page } from "../../components/Layout";
@ -13,11 +13,9 @@ function Index(): JSX.Element {
<Typography variant="h4">Scope</Typography>
</Box>
<Typography paragraph>
Scope rules are used by various modules in Hetty and can influence
their behavior. For example: the Proxy logs module can match incoming
requests against scope rules and decide its behavior (e.g. log or
bypass) based on the outcome of the match. All scope configuration is
stored per project.
Scope rules are used by various modules in Hetty and can influence their behavior. For example: the Proxy logs
module can match incoming requests against scope rules and decide its behavior (e.g. log or bypass) based on
the outcome of the match. All scope configuration is stored per project.
</Typography>
<Box my={4}>
<Divider />

View file

@ -1,4 +1,4 @@
import { Box, Typography } from "@material-ui/core";
import { Box, Typography } from "@mui/material";
import Layout, { Page } from "../../components/Layout";

View file

@ -8,7 +8,7 @@
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
@ -16,7 +16,8 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
"jsx": "preserve",
"incremental": true
},
"include": [
"next-env.d.ts",

File diff suppressed because it is too large Load diff