mirror of
https://github.com/dstotijn/hetty.git
synced 2025-07-01 18:47:29 -04:00
Reuse components across Proxy and Sender modules
This commit is contained in:
@ -27,6 +27,8 @@ import MuiListItemIcon, { ListItemIconProps } from "@mui/material/ListItemIcon";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useActiveProject } from "lib/ActiveProjectContext";
|
||||
|
||||
export enum Page {
|
||||
Home,
|
||||
GetStarted,
|
||||
@ -132,6 +134,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
const activeProject = useActiveProject();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@ -200,7 +203,7 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/proxy/logs" passHref>
|
||||
<ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
|
||||
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
|
||||
<Tooltip title="Proxy">
|
||||
<ListItemIcon>
|
||||
<SettingsEthernetIcon />
|
||||
@ -210,7 +213,7 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/sender" passHref>
|
||||
<ListItemButton key="sender" selected={page === Page.Sender}>
|
||||
<ListItemButton key="sender" disabled={!activeProject} selected={page === Page.Sender}>
|
||||
<Tooltip title="Sender">
|
||||
<ListItemIcon>
|
||||
<SendIcon />
|
||||
@ -220,7 +223,7 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/scope" passHref>
|
||||
<ListItemButton key="scope" selected={page === Page.Scope}>
|
||||
<ListItemButton key="scope" disabled={!activeProject} selected={page === Page.Scope}>
|
||||
<Tooltip title="Scope">
|
||||
<ListItemIcon>
|
||||
<LocationSearchingIcon />
|
||||
|
@ -1,104 +0,0 @@
|
||||
import { Alert } from "@mui/lab";
|
||||
import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar, SxProps, Theme } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
|
||||
const baseCellStyle: SxProps<Theme> = {
|
||||
px: 0,
|
||||
py: 0.33,
|
||||
verticalAlign: "top",
|
||||
border: "none",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
"&:hover": {
|
||||
color: "primary.main",
|
||||
whiteSpace: "inherit",
|
||||
overflow: "inherit",
|
||||
textOverflow: "inherit",
|
||||
cursor: "copy",
|
||||
},
|
||||
};
|
||||
|
||||
const keyCellStyle = {
|
||||
...baseCellStyle,
|
||||
pr: 1,
|
||||
width: "40%",
|
||||
fontWeight: "bold",
|
||||
fontSize: ".75rem",
|
||||
};
|
||||
|
||||
const valueCellStyle = {
|
||||
...baseCellStyle,
|
||||
width: "60%",
|
||||
border: "none",
|
||||
fontSize: ".75rem",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
function HttpHeadersTable({ headers }: Props): JSX.Element {
|
||||
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);
|
||||
windowSel.removeAllRanges();
|
||||
windowSel.addRange(r);
|
||||
document.execCommand("copy");
|
||||
windowSel.removeAllRanges();
|
||||
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Snackbar open={open} autoHideDuration={3000} onClose={handleClose}>
|
||||
<Alert onClose={handleClose} severity="info">
|
||||
Copied to clipboard.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<TableContainer>
|
||||
<Table
|
||||
sx={{
|
||||
tableLayout: "fixed",
|
||||
width: "100%",
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<TableBody>
|
||||
{headers.map(({ key, value }, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
|
||||
<code>{key}:</code>
|
||||
</TableCell>
|
||||
<TableCell sx={valueCellStyle} onClick={handleClick}>
|
||||
<code>{value}</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HttpHeadersTable;
|
@ -1,18 +1,20 @@
|
||||
import Alert from "@mui/lab/Alert";
|
||||
import { Box, Grid, Paper, CircularProgress } from "@mui/material";
|
||||
import { Box, CircularProgress, Paper, Typography } from "@mui/material";
|
||||
|
||||
import RequestDetail from "./RequestDetail";
|
||||
import ResponseDetail from "./ResponseDetail";
|
||||
|
||||
import Response from "lib/components/Response";
|
||||
import SplitPane from "lib/components/SplitPane";
|
||||
import { useHttpRequestLogQuery } from "lib/graphql/generated";
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function LogDetail({ requestId: id }: Props): JSX.Element {
|
||||
function LogDetail({ id }: Props): JSX.Element {
|
||||
const { loading, error, data } = useHttpRequestLogQuery({
|
||||
variables: { id },
|
||||
variables: { id: id as string },
|
||||
skip: id === undefined,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@ -31,28 +33,24 @@ function LogDetail({ requestId: id }: Props): JSX.Element {
|
||||
}
|
||||
|
||||
if (!data?.httpRequestLog) {
|
||||
return <div></div>;
|
||||
return (
|
||||
<Paper variant="centered" sx={{ mt: 2 }}>
|
||||
<Typography>Select a log entry…</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const httpRequestLog = data.httpRequestLog;
|
||||
const reqLog = data.httpRequestLog;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Grid container item spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Box component={Paper}>
|
||||
<RequestDetail request={httpRequestLog} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
{httpRequestLog.response && (
|
||||
<Box component={Paper}>
|
||||
<ResponseDetail response={httpRequestLog.response} />
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
<SplitPane split="vertical" size={"50%"}>
|
||||
<RequestDetail request={reqLog} />
|
||||
{reqLog.response && (
|
||||
<Box sx={{ height: "100%", pt: 1, pl: 2, pb: 2 }}>
|
||||
<Response response={reqLog.response} />
|
||||
</Box>
|
||||
)}
|
||||
</SplitPane>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,60 +0,0 @@
|
||||
import Alert from "@mui/lab/Alert";
|
||||
import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import LogDetail from "./LogDetail";
|
||||
import RequestList from "./RequestList";
|
||||
|
||||
import CenteredPaper from "lib/components/CenteredPaper";
|
||||
import { useHttpRequestLogsQuery } from "lib/graphql/generated";
|
||||
|
||||
export default function LogsOverview(): JSX.Element {
|
||||
const router = useRouter();
|
||||
const detailReqLogId = router.query.id as string | undefined;
|
||||
const { loading, error, data } = useHttpRequestLogsQuery({
|
||||
pollInterval: 1000,
|
||||
});
|
||||
|
||||
const handleLogClick = (reqId: string) => {
|
||||
router.push("/proxy/logs?id=" + reqId, undefined, {
|
||||
shallow: false,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
if (error) {
|
||||
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
|
||||
return (
|
||||
<Alert severity="info">
|
||||
There is no project active.{" "}
|
||||
<Link href="/projects" passHref>
|
||||
<MaterialLink color="primary">Create or open</MaterialLink>
|
||||
</Link>{" "}
|
||||
one first.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
|
||||
}
|
||||
|
||||
const logs = data?.httpRequestLogs || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Box mb={2}>
|
||||
<RequestList logs={logs} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
|
||||
</Box>
|
||||
<Box>
|
||||
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
|
||||
{logs.length !== 0 && !detailReqLogId && (
|
||||
<CenteredPaper>
|
||||
<Typography>Select a log entry…</Typography>
|
||||
</CenteredPaper>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,51 +1,46 @@
|
||||
import { Typography, Box, Divider } from "@mui/material";
|
||||
import { Typography, Box } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
import HttpHeadersTable from "./HttpHeadersTable";
|
||||
|
||||
import Editor from "lib/components/Editor";
|
||||
import RequestTabs from "lib/components/RequestTabs";
|
||||
import { HttpRequestLogQuery } from "lib/graphql/generated";
|
||||
import { queryParamsFromURL } from "lib/queryParamsFromURL";
|
||||
|
||||
interface Props {
|
||||
request: NonNullable<HttpRequestLogQuery["httpRequestLog"]>;
|
||||
}
|
||||
|
||||
function RequestDetail({ request }: Props): JSX.Element {
|
||||
const { method, url, proto, headers, body } = request;
|
||||
const { method, url, headers, body } = request;
|
||||
|
||||
const contentType = headers.find((header) => header.key === "Content-Type")?.value;
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Box p={2}>
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column", pr: 2, pb: 2 }}>
|
||||
<Box sx={{ p: 2, pb: 0 }}>
|
||||
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
|
||||
Request
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
width: "calc(100% - 80px)",
|
||||
fontSize: "1rem",
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
pr: 2,
|
||||
}}
|
||||
>
|
||||
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
|
||||
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{proto}
|
||||
</Typography>
|
||||
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box p={2}>
|
||||
<HttpHeadersTable headers={headers} />
|
||||
<Box flex="1 auto" overflow="scroll">
|
||||
<RequestTabs headers={headers} queryParams={queryParamsFromURL(url)} body={body} />
|
||||
</Box>
|
||||
|
||||
{body && <Editor content={body} contentType={contentType} />}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,167 +0,0 @@
|
||||
import {
|
||||
TableContainer,
|
||||
Paper,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
Typography,
|
||||
Box,
|
||||
useTheme,
|
||||
MenuItem,
|
||||
Snackbar,
|
||||
Alert,
|
||||
Link,
|
||||
} from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import CenteredPaper from "lib/components/CenteredPaper";
|
||||
import HttpStatusIcon from "lib/components/HttpStatusIcon";
|
||||
import useContextMenu from "lib/components/useContextMenu";
|
||||
import { HttpRequestLogsQuery, useCreateSenderRequestFromHttpRequestLogMutation } from "lib/graphql/generated";
|
||||
|
||||
interface Props {
|
||||
logs: NonNullable<HttpRequestLogsQuery["httpRequestLogs"]>;
|
||||
selectedReqLogId?: string;
|
||||
onLogClick(requestId: string): void;
|
||||
}
|
||||
|
||||
export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
|
||||
{logs.length === 0 && (
|
||||
<Box my={1}>
|
||||
<CenteredPaper>
|
||||
<Typography>No logs found.</Typography>
|
||||
</CenteredPaper>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestListTableProps {
|
||||
logs: HttpRequestLogsQuery["httpRequestLogs"];
|
||||
selectedReqLogId?: string;
|
||||
onLogClick(requestId: string): void;
|
||||
}
|
||||
|
||||
function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
|
||||
const theme = useTheme();
|
||||
|
||||
const [createSenderReqFromLog] = useCreateSenderRequestFromHttpRequestLogMutation({});
|
||||
|
||||
const [copyToSenderId, setCopyToSenderId] = useState("");
|
||||
const [Menu, handleContextMenu, handleContextMenuClose] = useContextMenu();
|
||||
|
||||
const handleCopyToSenderClick = () => {
|
||||
createSenderReqFromLog({
|
||||
variables: {
|
||||
id: copyToSenderId,
|
||||
},
|
||||
onCompleted({ createSenderRequestFromHttpRequestLog }) {
|
||||
const { id } = createSenderRequestFromHttpRequestLog;
|
||||
setNewSenderReqId(id);
|
||||
setCopiedReqNotifOpen(true);
|
||||
},
|
||||
});
|
||||
handleContextMenuClose();
|
||||
};
|
||||
|
||||
const [newSenderReqId, setNewSenderReqId] = useState("");
|
||||
const [copiedReqNotifOpen, setCopiedReqNotifOpen] = useState(false);
|
||||
const handleCloseCopiedNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
setCopiedReqNotifOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Menu>
|
||||
<MenuItem onClick={handleCopyToSenderClick}>Copy request to Sender</MenuItem>
|
||||
</Menu>
|
||||
<Snackbar
|
||||
open={copiedReqNotifOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseCopiedNotif}
|
||||
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
|
||||
>
|
||||
<Alert onClose={handleCloseCopiedNotif} severity="info">
|
||||
Request was copied. <Link href={`/sender?id=${newSenderReqId}`}>Edit in Sender.</Link>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
style={{
|
||||
minHeight: logs.length ? 200 : 0,
|
||||
height: logs.length ? "24vh" : "inherit",
|
||||
}}
|
||||
>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Method</TableCell>
|
||||
<TableCell>Origin</TableCell>
|
||||
<TableCell>Path</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.map(({ id, method, url, response }) => {
|
||||
const { origin, pathname, search, hash } = new URL(url);
|
||||
|
||||
const cellStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
...(id === selectedReqLogId && {
|
||||
bgcolor: theme.palette.action.selected,
|
||||
}),
|
||||
}}
|
||||
hover
|
||||
onClick={() => onLogClick(id)}
|
||||
onContextMenu={(e) => {
|
||||
setCopyToSenderId(id);
|
||||
handleContextMenu(e);
|
||||
}}
|
||||
>
|
||||
<TableCell sx={{ ...cellStyle, width: "100px" }}>
|
||||
<code>{method}</code>
|
||||
</TableCell>
|
||||
<TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
|
||||
<TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
|
||||
{decodeURIComponent(pathname + search + hash)}
|
||||
</TableCell>
|
||||
<TableCell style={{ maxWidth: "100px" }}>
|
||||
{response && (
|
||||
<div>
|
||||
<HttpStatusIcon status={response.statusCode} />{" "}
|
||||
<code>
|
||||
{response.statusCode} {response.statusReason}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
90
admin/src/features/reqlog/components/RequestLogs.tsx
Normal file
90
admin/src/features/reqlog/components/RequestLogs.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Alert, Box, Link, MenuItem, Snackbar } from "@mui/material";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import LogDetail from "./LogDetail";
|
||||
import Search from "./Search";
|
||||
|
||||
import RequestsTable from "lib/components/RequestsTable";
|
||||
import SplitPane from "lib/components/SplitPane";
|
||||
import useContextMenu from "lib/components/useContextMenu";
|
||||
import { useCreateSenderRequestFromHttpRequestLogMutation, useHttpRequestLogsQuery } from "lib/graphql/generated";
|
||||
|
||||
export function RequestLogs(): JSX.Element {
|
||||
const router = useRouter();
|
||||
const id = router.query.id as string | undefined;
|
||||
const { data } = useHttpRequestLogsQuery({
|
||||
pollInterval: 1000,
|
||||
});
|
||||
|
||||
const [createSenderReqFromLog] = useCreateSenderRequestFromHttpRequestLogMutation({});
|
||||
|
||||
const [copyToSenderId, setCopyToSenderId] = useState("");
|
||||
const [Menu, handleContextMenu, handleContextMenuClose] = useContextMenu();
|
||||
|
||||
const handleCopyToSenderClick = () => {
|
||||
createSenderReqFromLog({
|
||||
variables: {
|
||||
id: copyToSenderId,
|
||||
},
|
||||
onCompleted({ createSenderRequestFromHttpRequestLog }) {
|
||||
const { id } = createSenderRequestFromHttpRequestLog;
|
||||
setNewSenderReqId(id);
|
||||
setCopiedReqNotifOpen(true);
|
||||
},
|
||||
});
|
||||
handleContextMenuClose();
|
||||
};
|
||||
|
||||
const [newSenderReqId, setNewSenderReqId] = useState("");
|
||||
const [copiedReqNotifOpen, setCopiedReqNotifOpen] = useState(false);
|
||||
const handleCloseCopiedNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
setCopiedReqNotifOpen(false);
|
||||
};
|
||||
|
||||
const handleRowClick = (id: string) => {
|
||||
router.push(`/proxy/logs?id=${id}`);
|
||||
};
|
||||
|
||||
const handleRowContextClick = (e: React.MouseEvent, id: string) => {
|
||||
setCopyToSenderId(id);
|
||||
handleContextMenu(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" height="100%">
|
||||
<Search />
|
||||
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
|
||||
<SplitPane split="horizontal" size={"40%"}>
|
||||
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
|
||||
<Box sx={{ width: "100%", height: "100%", overflow: "scroll" }}>
|
||||
<Menu>
|
||||
<MenuItem onClick={handleCopyToSenderClick}>Copy request to Sender</MenuItem>
|
||||
</Menu>
|
||||
<Snackbar
|
||||
open={copiedReqNotifOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleCloseCopiedNotif}
|
||||
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
|
||||
>
|
||||
<Alert onClose={handleCloseCopiedNotif} severity="info">
|
||||
Request was copied. <Link href={`/sender?id=${newSenderReqId}`}>Edit in Sender.</Link>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<RequestsTable
|
||||
requests={data?.httpRequestLogs || []}
|
||||
activeRowId={id}
|
||||
onRowClick={handleRowClick}
|
||||
onContextMenu={handleRowContextClick}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<LogDetail id={id} />
|
||||
</SplitPane>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
import { Typography, Box, Divider } from "@mui/material";
|
||||
|
||||
import HttpHeadersTable from "./HttpHeadersTable";
|
||||
|
||||
import Editor from "lib/components/Editor";
|
||||
import HttpStatusIcon from "lib/components/HttpStatusIcon";
|
||||
import { HttpRequestLogQuery } from "lib/graphql/generated";
|
||||
|
||||
interface Props {
|
||||
response: NonNullable<NonNullable<HttpRequestLogQuery["httpRequestLog"]>["response"]>;
|
||||
}
|
||||
|
||||
function ResponseDetail({ response }: Props): JSX.Element {
|
||||
const contentType = response.headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Box p={2}>
|
||||
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
|
||||
Response
|
||||
</Typography>
|
||||
<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" }}>
|
||||
{response.proto}
|
||||
</Typography>
|
||||
</Typography>{" "}
|
||||
{response.statusCode} {response.statusReason}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box p={2}>
|
||||
<HttpHeadersTable headers={response.headers} />
|
||||
</Box>
|
||||
|
||||
{response.body && <Editor content={response.body} contentType={contentType} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponseDetail;
|
@ -17,8 +17,7 @@ import {
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "./ConfirmationDialog";
|
||||
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
|
||||
import {
|
||||
HttpRequestLogFilterDocument,
|
||||
HttpRequestLogsDocument,
|
||||
|
@ -10,6 +10,7 @@ query HttpRequestLog($id: ID!) {
|
||||
}
|
||||
body
|
||||
response {
|
||||
id
|
||||
proto
|
||||
headers {
|
||||
key
|
||||
|
3
admin/src/features/reqlog/index.ts
Normal file
3
admin/src/features/reqlog/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { RequestLogs } from "./components/RequestLogs";
|
||||
|
||||
export default RequestLogs;
|
@ -10,14 +10,13 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { AllotmentProps, PaneProps } from "allotment/dist/types/src/allotment";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import EditRequestTabs from "./EditRequestTabs";
|
||||
import { KeyValuePair, sortKeyValuePairs } from "./KeyValuePair";
|
||||
import Response from "./Response";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
|
||||
import RequestTabs from "lib/components/RequestTabs";
|
||||
import Response from "lib/components/Response";
|
||||
import SplitPane from "lib/components/SplitPane";
|
||||
import {
|
||||
GetSenderRequestQuery,
|
||||
useCreateOrUpdateSenderRequestMutation,
|
||||
@ -25,8 +24,7 @@ import {
|
||||
useGetSenderRequestQuery,
|
||||
useSendRequestMutation,
|
||||
} from "lib/graphql/generated";
|
||||
|
||||
import "allotment/dist/style.css";
|
||||
import { queryParamsFromURL } from "lib/queryParamsFromURL";
|
||||
|
||||
enum HttpMethod {
|
||||
Get = "GET",
|
||||
@ -88,22 +86,6 @@ function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
|
||||
return newURL + "?" + rawQueryParams;
|
||||
}
|
||||
|
||||
function queryParamsFromURL(url: string): KeyValuePair[] {
|
||||
const questionMarkIndex = url.indexOf("?");
|
||||
if (questionMarkIndex === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
const searchParams = new URLSearchParams(url.slice(questionMarkIndex + 1));
|
||||
for (const [key, value] of searchParams) {
|
||||
queryParams.push({ key, value });
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
function EditRequest(): JSX.Element {
|
||||
const router = useRouter();
|
||||
const reqId = router.query.id as string | undefined;
|
||||
@ -219,28 +201,6 @@ function EditRequest(): JSX.Element {
|
||||
createOrUpdateRequestAndSend();
|
||||
};
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
const [Allotment, setAllotment] = useState<
|
||||
(React.ComponentType<AllotmentProps> & { Pane: React.ComponentType<PaneProps> }) | null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
import("allotment")
|
||||
.then((mod) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setAllotment(mod.Allotment);
|
||||
})
|
||||
.catch((err) => console.error(err, `could not import allotment ${err.message}`));
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
if (!Allotment) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" height="100%" gap={2}>
|
||||
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
|
||||
@ -276,31 +236,27 @@ function EditRequest(): JSX.Element {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flex="1 auto" overflow="hidden">
|
||||
<Allotment>
|
||||
<Box pr={2} pb={2} height="100%" overflow="hidden">
|
||||
<Box height="100%" position="relative">
|
||||
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
|
||||
Request
|
||||
</Typography>
|
||||
<EditRequestTabs
|
||||
queryParams={queryParams}
|
||||
headers={headers}
|
||||
body={body}
|
||||
onQueryParamChange={handleQueryParamChange}
|
||||
onQueryParamDelete={handleQueryParamDelete}
|
||||
onHeaderChange={handleHeaderChange}
|
||||
onHeaderDelete={handleHeaderDelete}
|
||||
onBodyChange={setBody}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="1 auto" position="relative">
|
||||
<SplitPane split="vertical" size={"50%"}>
|
||||
<Box sx={{ height: "100%", mr: 2, pb: 2, position: "relative" }}>
|
||||
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
|
||||
Request
|
||||
</Typography>
|
||||
<RequestTabs
|
||||
queryParams={queryParams}
|
||||
headers={headers}
|
||||
body={body}
|
||||
onQueryParamChange={handleQueryParamChange}
|
||||
onQueryParamDelete={handleQueryParamDelete}
|
||||
onHeaderChange={handleHeaderChange}
|
||||
onHeaderDelete={handleHeaderDelete}
|
||||
onBodyChange={setBody}
|
||||
/>
|
||||
</Box>
|
||||
<Box pb={2} pl={2} height="100%" overflow="hidden">
|
||||
<Box height="100%" position="relative">
|
||||
<Response response={response} />
|
||||
</Box>
|
||||
<Box sx={{ height: "100%", position: "relative", ml: 2, pb: 2 }}>
|
||||
<Response response={response} />
|
||||
</Box>
|
||||
</Allotment>
|
||||
</SplitPane>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { TableContainer, Table, TableHead, TableRow, TableCell, Typography, Box, TableBody } from "@mui/material";
|
||||
import { Box, Paper, Typography } from "@mui/material";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import CenteredPaper from "lib/components/CenteredPaper";
|
||||
import HttpStatusIcon from "lib/components/HttpStatusIcon";
|
||||
import RequestsTable from "lib/components/RequestsTable";
|
||||
import { useGetSenderRequestsQuery } from "lib/graphql/generated";
|
||||
|
||||
function History(): JSX.Element {
|
||||
@ -20,71 +19,13 @@ function History(): JSX.Element {
|
||||
return (
|
||||
<Box>
|
||||
{!loading && data?.senderRequests && data?.senderRequests.length > 0 && (
|
||||
<TableContainer sx={{ overflowX: "initial" }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Method</TableCell>
|
||||
<TableCell>Origin</TableCell>
|
||||
<TableCell>Path</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data?.senderRequests &&
|
||||
data.senderRequests.map(({ id, method, url, response }) => {
|
||||
const { origin, pathname, search, hash } = new URL(url);
|
||||
|
||||
const cellStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
...(id === activeId && {
|
||||
bgcolor: "action.selected",
|
||||
cursor: "inherit",
|
||||
}),
|
||||
}}
|
||||
hover
|
||||
onClick={() => handleRowClick(id)}
|
||||
>
|
||||
<TableCell sx={{ ...cellStyle, width: "100px" }}>
|
||||
<code>{method}</code>
|
||||
</TableCell>
|
||||
<TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
|
||||
<TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
|
||||
{decodeURIComponent(pathname + search + hash)}
|
||||
</TableCell>
|
||||
<TableCell style={{ maxWidth: "100px" }}>
|
||||
{response && (
|
||||
<div>
|
||||
<HttpStatusIcon status={response.statusCode} />{" "}
|
||||
<code>
|
||||
{response.statusCode} {response.statusReason}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<RequestsTable requests={data.senderRequests} onRowClick={handleRowClick} activeRowId={activeId} />
|
||||
)}
|
||||
<Box sx={{ mt: 2, height: "100%" }}>
|
||||
{!loading && data?.senderRequests.length === 0 && (
|
||||
<CenteredPaper>
|
||||
<Paper variant="centered">
|
||||
<Typography>No requests created yet.</Typography>
|
||||
</CenteredPaper>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
@ -1,130 +0,0 @@
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import { IconButton, InputBase, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeyValuePairTableProps {
|
||||
items: KeyValuePair[];
|
||||
onChange?: (key: string, value: string, index: number) => void;
|
||||
onDelete?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTableProps): JSX.Element {
|
||||
const inputSx = {
|
||||
fontSize: "0.875rem",
|
||||
"&.MuiInputBase-root input": {
|
||||
p: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
{onDelete && <TableCell padding="checkbox"></TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody
|
||||
sx={{
|
||||
"td, th, input": {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: "0.75rem",
|
||||
py: 0.2,
|
||||
},
|
||||
"td span, th span": {
|
||||
display: "block",
|
||||
py: 0.7,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{items.map(({ key, value }, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
hover
|
||||
sx={{
|
||||
"& .delete-button": {
|
||||
visibility: "hidden",
|
||||
},
|
||||
"&:hover .delete-button": {
|
||||
visibility: "inherit",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{!onChange && <span>{key}</span>}
|
||||
{onChange && (
|
||||
<InputBase
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
onChange && onChange(e.target.value, value, idx);
|
||||
}}
|
||||
sx={inputSx}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: "60%", wordBreak: "break-all" }}>
|
||||
{!onChange && value}
|
||||
{onChange && (
|
||||
<InputBase
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange && onChange(key, e.target.value, idx);
|
||||
}}
|
||||
sx={inputSx}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{onDelete && (
|
||||
<TableCell>
|
||||
<div className="delete-button">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onDelete && onDelete(idx);
|
||||
}}
|
||||
sx={{
|
||||
visibility: onDelete === undefined || items.length === idx + 1 ? "hidden" : "inherit",
|
||||
}}
|
||||
>
|
||||
<ClearIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
|
||||
const sorted = [...items];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
if (a.key < b.key) {
|
||||
return -1;
|
||||
}
|
||||
if (a.key > b.key) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export default KeyValuePairTable;
|
21
admin/src/features/sender/components/Sender.tsx
Normal file
21
admin/src/features/sender/components/Sender.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import EditRequest from "./EditRequest";
|
||||
import History from "./History";
|
||||
|
||||
import SplitPane from "lib/components/SplitPane";
|
||||
|
||||
export default function Sender(): JSX.Element {
|
||||
return (
|
||||
<Box sx={{ height: "100%", position: "relative" }}>
|
||||
<SplitPane split="horizontal" size="70%">
|
||||
<Box sx={{ width: "100%", pt: "0.75rem" }}>
|
||||
<EditRequest />
|
||||
</Box>
|
||||
<Box sx={{ height: "100%", overflow: "scroll" }}>
|
||||
<History />
|
||||
</Box>
|
||||
</SplitPane>
|
||||
</Box>
|
||||
);
|
||||
}
|
3
admin/src/features/sender/index.ts
Normal file
3
admin/src/features/sender/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Sender from "./components/Sender";
|
||||
|
||||
export default Sender;
|
20
admin/src/lib/ActiveProjectContext.tsx
Normal file
20
admin/src/lib/ActiveProjectContext.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
import { Project, useProjectsQuery } from "./graphql/generated";
|
||||
|
||||
const ActiveProjectContext = createContext<Project | null>(null);
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode | undefined;
|
||||
}
|
||||
|
||||
export function ActiveProjectProvider({ children }: Props): JSX.Element {
|
||||
const { data } = useProjectsQuery();
|
||||
const project = data?.projects.find((project) => project.isActive) || null;
|
||||
|
||||
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
|
||||
}
|
||||
|
||||
export function useActiveProject() {
|
||||
return useContext(ActiveProjectContext);
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { Paper } from "@mui/material";
|
||||
|
||||
function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Paper
|
||||
elevation={0}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 36,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CenteredPaper;
|
201
admin/src/lib/components/KeyValuePair.tsx
Normal file
201
admin/src/lib/components/KeyValuePair.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import {
|
||||
Alert,
|
||||
IconButton,
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
Snackbar,
|
||||
styled,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellProps,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableRowProps,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
|
||||
const StyledInputBase = styled(InputBase)<InputBaseProps>(() => ({
|
||||
fontSize: "0.875rem",
|
||||
"&.MuiInputBase-root input": {
|
||||
p: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTableRow = styled(TableRow)<TableRowProps>(() => ({
|
||||
"& .delete-button": {
|
||||
visibility: "hidden",
|
||||
},
|
||||
"&:hover .delete-button": {
|
||||
visibility: "inherit",
|
||||
},
|
||||
}));
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeyValuePairTableProps {
|
||||
items: KeyValuePair[];
|
||||
onChange?: (key: string, value: string, index: number) => void;
|
||||
onDelete?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTableProps): JSX.Element {
|
||||
const [copyConfOpen, setCopyConfOpen] = useState(false);
|
||||
|
||||
const handleCellClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const windowSel = window.getSelection();
|
||||
|
||||
if (!windowSel || !document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = document.createRange();
|
||||
r.selectNode(e.currentTarget);
|
||||
windowSel.removeAllRanges();
|
||||
windowSel.addRange(r);
|
||||
document.execCommand("copy");
|
||||
windowSel.removeAllRanges();
|
||||
|
||||
setCopyConfOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyConfClose = (_: Event | React.SyntheticEvent, reason?: string) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
|
||||
setCopyConfOpen(false);
|
||||
};
|
||||
|
||||
const baseCellStyle = {
|
||||
"&:hover": {
|
||||
cursor: "copy",
|
||||
},
|
||||
};
|
||||
|
||||
const KeyTableCell = styled(TableCell)<TableCellProps>(() => (!onChange ? baseCellStyle : {}));
|
||||
const ValueTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
...(!onChange && baseCellStyle),
|
||||
width: "60%",
|
||||
wordBreak: "break-all",
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Snackbar open={copyConfOpen} autoHideDuration={3000} onClose={handleCopyConfClose}>
|
||||
<Alert onClose={handleCopyConfClose} severity="info">
|
||||
Copied to clipboard.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<TableContainer sx={{ overflowX: "initial" }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Key</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
{onDelete && <TableCell padding="checkbox"></TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody
|
||||
sx={{
|
||||
"td, th, input": {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: "0.75rem",
|
||||
py: 0.2,
|
||||
},
|
||||
"td span, th span": {
|
||||
display: "block",
|
||||
py: 0.7,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{items.map(({ key, value }, idx) => (
|
||||
<StyledTableRow key={idx} hover>
|
||||
<KeyTableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
onClick={(e) => {
|
||||
!onChange && handleCellClick(e);
|
||||
}}
|
||||
>
|
||||
{!onChange && <span>{key}</span>}
|
||||
{onChange && (
|
||||
<StyledInputBase
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
onChange && onChange(e.target.value, value, idx);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</KeyTableCell>
|
||||
<ValueTableCell
|
||||
onClick={(e) => {
|
||||
!onChange && handleCellClick(e);
|
||||
}}
|
||||
>
|
||||
{!onChange && value}
|
||||
{onChange && (
|
||||
<StyledInputBase
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange && onChange(key, e.target.value, idx);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ValueTableCell>
|
||||
{onDelete && (
|
||||
<TableCell>
|
||||
<div className="delete-button">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onDelete && onDelete(idx);
|
||||
}}
|
||||
sx={{
|
||||
visibility: onDelete === undefined || items.length === idx + 1 ? "hidden" : "inherit",
|
||||
}}
|
||||
>
|
||||
<ClearIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</StyledTableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
|
||||
const sorted = [...items];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
if (a.key < b.key) {
|
||||
return -1;
|
||||
}
|
||||
if (a.key > b.key) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export default KeyValuePairTable;
|
@ -12,18 +12,18 @@ enum TabValue {
|
||||
Body = "body",
|
||||
}
|
||||
|
||||
interface EditRequestTabsProps {
|
||||
interface RequestTabsProps {
|
||||
queryParams: KeyValuePair[];
|
||||
headers: KeyValuePair[];
|
||||
onQueryParamChange: KeyValuePairTableProps["onChange"];
|
||||
onQueryParamDelete: KeyValuePairTableProps["onDelete"];
|
||||
onHeaderChange: KeyValuePairTableProps["onChange"];
|
||||
onHeaderDelete: KeyValuePairTableProps["onDelete"];
|
||||
body: string;
|
||||
onBodyChange: (value: string) => void;
|
||||
onQueryParamChange?: KeyValuePairTableProps["onChange"];
|
||||
onQueryParamDelete?: KeyValuePairTableProps["onDelete"];
|
||||
onHeaderChange?: KeyValuePairTableProps["onChange"];
|
||||
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
|
||||
body?: string | null;
|
||||
onBodyChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function EditRequestTabs(props: EditRequestTabsProps): JSX.Element {
|
||||
function RequestTabs(props: RequestTabsProps): JSX.Element {
|
||||
const {
|
||||
queryParams,
|
||||
onQueryParamChange,
|
||||
@ -40,46 +40,45 @@ function EditRequestTabs(props: EditRequestTabsProps): JSX.Element {
|
||||
textTransform: "none",
|
||||
};
|
||||
|
||||
const queryParamsLength = onQueryParamChange ? queryParams.length - 1 : queryParams.length;
|
||||
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
|
||||
|
||||
return (
|
||||
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<TabContext value={tabValue}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 2 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
|
||||
<TabList onChange={(_, value) => setTabValue(value)}>
|
||||
<Tab
|
||||
value={TabValue.QueryParams}
|
||||
label={"Query Params" + (queryParams.length - 1 ? ` (${queryParams.length - 1})` : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
<Tab
|
||||
value={TabValue.Headers}
|
||||
label={"Headers" + (headers.length - 1 ? ` (${headers.length - 1})` : "")}
|
||||
label={"Query Params" + (queryParamsLength ? ` (${queryParamsLength})` : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
|
||||
<Tab
|
||||
value={TabValue.Body}
|
||||
label={"Body" + (body.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
|
||||
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
</TabList>
|
||||
</Box>
|
||||
<Box flex="1 auto" overflow="hidden">
|
||||
<TabPanel value={TabValue.QueryParams} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
|
||||
<Box flex="1 auto" overflow="scroll" height="100%">
|
||||
<TabPanel value={TabValue.QueryParams} sx={{ p: 0, height: "100%" }}>
|
||||
<Box>
|
||||
<KeyValuePairTable items={queryParams} onChange={onQueryParamChange} onDelete={onQueryParamDelete} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
|
||||
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%" }}>
|
||||
<Box>
|
||||
<KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
|
||||
<Editor
|
||||
content={body}
|
||||
content={body || ""}
|
||||
onChange={(value) => {
|
||||
onBodyChange(value || "");
|
||||
onBodyChange && onBodyChange(value || "");
|
||||
}}
|
||||
monacoOptions={{ readOnly: false }}
|
||||
monacoOptions={{ readOnly: onBodyChange === undefined }}
|
||||
contentType={headers.find(({ key }) => key.toLowerCase() === "content-type")?.value}
|
||||
/>
|
||||
</TabPanel>
|
||||
@ -89,4 +88,4 @@ function EditRequestTabs(props: EditRequestTabsProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default EditRequestTabs;
|
||||
export default RequestTabs;
|
125
admin/src/lib/components/RequestsTable.tsx
Normal file
125
admin/src/lib/components/RequestsTable.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import {
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
styled,
|
||||
TableCellProps,
|
||||
TableRowProps,
|
||||
} from "@mui/material";
|
||||
|
||||
import HttpStatusIcon from "./HttpStatusIcon";
|
||||
|
||||
import { HttpMethod } from "lib/graphql/generated";
|
||||
|
||||
const baseCellStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
} as const;
|
||||
|
||||
const MethodTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
...baseCellStyle,
|
||||
width: "100px",
|
||||
}));
|
||||
|
||||
const OriginTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
...baseCellStyle,
|
||||
maxWidth: "100px",
|
||||
}));
|
||||
|
||||
const PathTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
...baseCellStyle,
|
||||
maxWidth: "200px",
|
||||
}));
|
||||
|
||||
const StatusTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
...baseCellStyle,
|
||||
width: "100px",
|
||||
}));
|
||||
|
||||
const RequestTableRow = styled(TableRow)<TableRowProps>(() => ({
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
}));
|
||||
|
||||
interface HttpRequest {
|
||||
id: string;
|
||||
url: string;
|
||||
method: HttpMethod;
|
||||
response?: HttpResponse | null;
|
||||
}
|
||||
|
||||
interface HttpResponse {
|
||||
statusCode: number;
|
||||
statusReason: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
requests: HttpRequest[];
|
||||
activeRowId?: string;
|
||||
onRowClick?: (id: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, id: string) => void;
|
||||
}
|
||||
|
||||
export default function RequestsTable(props: Props): JSX.Element {
|
||||
const { requests, activeRowId, onRowClick, onContextMenu } = props;
|
||||
|
||||
return (
|
||||
<TableContainer sx={{ overflowX: "initial" }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Method</TableCell>
|
||||
<TableCell>Origin</TableCell>
|
||||
<TableCell>Path</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{requests.map(({ id, method, url, response }) => {
|
||||
const { origin, pathname, search, hash } = new URL(url);
|
||||
|
||||
return (
|
||||
<RequestTableRow
|
||||
key={id}
|
||||
hover
|
||||
selected={id === activeRowId}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(id);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
onContextMenu && onContextMenu(e, id);
|
||||
}}
|
||||
>
|
||||
<MethodTableCell>
|
||||
<code>{method}</code>
|
||||
</MethodTableCell>
|
||||
<OriginTableCell>{origin}</OriginTableCell>
|
||||
<PathTableCell>{decodeURIComponent(pathname + search + hash)}</PathTableCell>
|
||||
<StatusTableCell>
|
||||
{response && <Status code={response.statusCode} reason={response.statusReason} />}
|
||||
</StatusTableCell>
|
||||
</RequestTableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ code, reason }: { code: number; reason: string }): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<HttpStatusIcon status={code} />{" "}
|
||||
<code>
|
||||
{code} {reason}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -13,22 +13,20 @@ interface ResponseProps {
|
||||
function Response({ response }: ResponseProps): JSX.Element {
|
||||
return (
|
||||
<Box height="100%">
|
||||
<div>
|
||||
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
|
||||
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
|
||||
Response
|
||||
</Typography>
|
||||
{response && (
|
||||
<Box sx={{ float: "right", mt: 0.2 }}>
|
||||
<ResponseStatus
|
||||
proto={response.proto}
|
||||
statusCode={response.statusCode}
|
||||
statusReason={response.statusReason}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
|
||||
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
|
||||
Response
|
||||
</Typography>
|
||||
{response && (
|
||||
<Box sx={{ float: "right", mt: 0.2 }}>
|
||||
<ResponseStatus
|
||||
proto={response.proto}
|
||||
statusCode={response.statusCode}
|
||||
statusReason={response.statusReason}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<ResponseTabs
|
||||
body={response?.body}
|
||||
headers={sortKeyValuePairs(response?.headers || [])}
|
@ -1,11 +1,9 @@
|
||||
import { TabContext, TabList, TabPanel } from "@mui/lab";
|
||||
import { Box, Tab, Typography } from "@mui/material";
|
||||
import { Box, Paper, Tab, Typography } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { KeyValuePairTable } from "./KeyValuePair";
|
||||
|
||||
import CenteredPaper from "lib/components/CenteredPaper";
|
||||
import Editor from "lib/components/Editor";
|
||||
import { KeyValuePairTable } from "lib/components/KeyValuePair";
|
||||
import { HttpResponseLog } from "lib/graphql/generated";
|
||||
|
||||
interface ResponseTabsProps {
|
||||
@ -20,9 +18,9 @@ enum TabValue {
|
||||
}
|
||||
|
||||
const reqNotSent = (
|
||||
<CenteredPaper>
|
||||
<Paper variant="centered">
|
||||
<Typography>Response not received yet.</Typography>
|
||||
</CenteredPaper>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||
@ -38,7 +36,7 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||
return (
|
||||
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<TabContext value={tabValue}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 2 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
|
||||
<TabList onChange={(_, value) => setTabValue(value)}>
|
||||
<Tab
|
||||
value={TabValue.Body}
|
53
admin/src/lib/components/SplitPane.tsx
Normal file
53
admin/src/lib/components/SplitPane.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { alpha, styled } from "@mui/material/styles";
|
||||
import ReactSplitPane, { SplitPaneProps } from "react-split-pane";
|
||||
|
||||
const BORDER_WIDTH_FACTOR = 1.75;
|
||||
const SIZE_FACTOR = 4;
|
||||
const MARGIN_FACTOR = -1.75;
|
||||
|
||||
const SplitPane = styled(ReactSplitPane)<SplitPaneProps>(({ theme }) => ({
|
||||
".Resizer": {
|
||||
zIndex: theme.zIndex.mobileStepper,
|
||||
boxSizing: "border-box",
|
||||
backgroundClip: "padding-box",
|
||||
backgroundColor: alpha(theme.palette.grey[400], 0.05),
|
||||
},
|
||||
".Resizer:hover": {
|
||||
transition: "all 0.5s ease",
|
||||
backgroundColor: alpha(theme.palette.primary.main, 1),
|
||||
},
|
||||
|
||||
".Resizer.horizontal": {
|
||||
height: theme.spacing(SIZE_FACTOR),
|
||||
marginTop: theme.spacing(MARGIN_FACTOR),
|
||||
marginBottom: theme.spacing(MARGIN_FACTOR),
|
||||
borderTop: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
|
||||
borderBottom: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
|
||||
borderBottomColor: "rgba(255, 255, 255, 0)",
|
||||
cursor: "row-resize",
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
".Resizer.vertical": {
|
||||
width: theme.spacing(SIZE_FACTOR),
|
||||
marginLeft: theme.spacing(MARGIN_FACTOR),
|
||||
marginRight: theme.spacing(MARGIN_FACTOR),
|
||||
borderLeft: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
|
||||
borderRight: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
|
||||
cursor: "col-resize",
|
||||
},
|
||||
|
||||
".Resizer.disabled": {
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
|
||||
".Resizer.disabled:hover": {
|
||||
borderColor: "transparent",
|
||||
},
|
||||
|
||||
".Pane": {
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
export default SplitPane;
|
@ -288,7 +288,7 @@ export type HttpRequestLogQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type HttpRequestLogQuery = { __typename?: 'Query', httpRequestLog?: { __typename?: 'HttpRequestLog', id: string, method: HttpMethod, url: string, proto: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }>, response?: { __typename?: 'HttpResponseLog', proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
|
||||
export type HttpRequestLogQuery = { __typename?: 'Query', httpRequestLog?: { __typename?: 'HttpRequestLog', id: string, method: HttpMethod, url: string, proto: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }>, response?: { __typename?: 'HttpResponseLog', id: string, proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
|
||||
|
||||
export type HttpRequestLogFilterQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@ -568,6 +568,7 @@ export const HttpRequestLogDocument = gql`
|
||||
}
|
||||
body
|
||||
response {
|
||||
id
|
||||
proto
|
||||
headers {
|
||||
key
|
||||
|
@ -1,6 +1,12 @@
|
||||
import * as colors from "@mui/material/colors";
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
declare module "@mui/material/Paper" {
|
||||
interface PaperPropsVariantOverrides {
|
||||
centered: true;
|
||||
}
|
||||
}
|
||||
|
||||
const heading = {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
@ -41,13 +47,28 @@ theme = createTheme(theme, {
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiTableCell: {
|
||||
MuiTableRow: {
|
||||
styleOverrides: {
|
||||
stickyHeader: {
|
||||
backgroundColor: theme.palette.secondary.dark,
|
||||
root: {
|
||||
"&.Mui-selected, &.Mui-selected:hover": {
|
||||
backgroundColor: theme.palette.grey[700],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
variants: [
|
||||
{
|
||||
props: { variant: "centered" },
|
||||
style: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(4),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
17
admin/src/lib/queryParamsFromURL.tsx
Normal file
17
admin/src/lib/queryParamsFromURL.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { KeyValuePair } from "./components/KeyValuePair";
|
||||
|
||||
export function queryParamsFromURL(url: string): KeyValuePair[] {
|
||||
const questionMarkIndex = url.indexOf("?");
|
||||
if (questionMarkIndex === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
const searchParams = new URLSearchParams(url.slice(questionMarkIndex + 1));
|
||||
for (const [key, value] of searchParams) {
|
||||
queryParams.push({ key, value });
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import { CacheProvider, EmotionCache } from "@emotion/react";
|
||||
import { ThemeProvider } from "@mui/material";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import React from "react";
|
||||
|
||||
import { ActiveProjectProvider } from "lib/ActiveProjectContext";
|
||||
import { useApollo } from "lib/graphql/useApollo";
|
||||
import createEmotionCache from "lib/mui/createEmotionCache";
|
||||
import theme from "lib/mui/theme";
|
||||
@ -30,10 +31,12 @@ export default function MyApp(props: MyAppProps) {
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
</Head>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<ActiveProjectProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</ActiveProjectProvider>
|
||||
</ApolloProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
|
@ -1,16 +1,10 @@
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { Layout, Page } from "features/Layout";
|
||||
import LogsOverview from "features/reqlog/components/LogsOverview";
|
||||
import Search from "features/reqlog/components/Search";
|
||||
import RequestLogs from "features/reqlog";
|
||||
|
||||
function ProxyLogs(): JSX.Element {
|
||||
return (
|
||||
<Layout page={Page.ProxyLogs} title="Proxy logs">
|
||||
<Box mb={2}>
|
||||
<Search />
|
||||
</Box>
|
||||
<LogsOverview />
|
||||
<RequestLogs />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
@ -1,47 +1,10 @@
|
||||
import { Box } from "@mui/system";
|
||||
import { AllotmentProps } from "allotment";
|
||||
import { PaneProps } from "allotment/dist/types/src/allotment";
|
||||
import { ComponentType, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Layout, Page } from "features/Layout";
|
||||
import EditRequest from "features/sender/components/EditRequest";
|
||||
import History from "features/sender/components/History";
|
||||
import Sender from "features/sender";
|
||||
|
||||
function Index(): JSX.Element {
|
||||
const isMountedRef = useRef(false);
|
||||
const [Allotment, setAllotment] = useState<
|
||||
(ComponentType<AllotmentProps> & { Pane: ComponentType<PaneProps> }) | null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
import("allotment")
|
||||
.then((mod) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setAllotment(mod.Allotment);
|
||||
})
|
||||
.catch((err) => console.error(err, `could not import allotment ${err.message}`));
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
if (!Allotment) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout page={Page.Sender} title="Sender">
|
||||
<Allotment vertical={true} defaultSizes={[70, 30]}>
|
||||
<Box sx={{ pt: 0.75, height: "100%" }}>
|
||||
<EditRequest />
|
||||
</Box>
|
||||
<Box sx={{ height: "100%", py: 2, overflow: "hidden" }}>
|
||||
<Box sx={{ height: "100%", overflow: "scroll" }}>
|
||||
<History />
|
||||
</Box>
|
||||
</Box>
|
||||
</Allotment>
|
||||
<Sender />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user