diff --git a/src/components/connection/connection-detail.tsx b/src/components/connection/connection-detail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ed786e2e24b1acef78f67f5a72f771359c600a7 --- /dev/null +++ b/src/components/connection/connection-detail.tsx @@ -0,0 +1,104 @@ +import dayjs from "dayjs"; +import { forwardRef, useImperativeHandle, useState } from "react"; +import { useLockFn } from "ahooks"; +import { Box, Button, Snackbar } from "@mui/material"; +import { deleteConnection } from "@/services/api"; +import { truncateStr } from "@/utils/truncate-str"; +import parseTraffic from "@/utils/parse-traffic"; + +export interface ConnectionDetailRef { + open: (detail: IConnectionsItem) => void; +} + +export const ConnectionDetail = forwardRef<ConnectionDetailRef>( + (props, ref) => { + const [open, setOpen] = useState(false); + const [detail, setDetail] = useState<IConnectionsItem>(null!); + + useImperativeHandle(ref, () => ({ + open: (detail: IConnectionsItem) => { + if (open) return; + setOpen(true); + setDetail(detail); + }, + })); + + const onClose = () => setOpen(false); + + return ( + <Snackbar + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + open={open} + onClose={onClose} + message={ + detail ? ( + <InnerConnectionDetail data={detail} onClose={onClose} /> + ) : null + } + /> + ); + } +); + +interface InnerProps { + data: IConnectionsItem; + onClose?: () => void; +} + +const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { + const { metadata, rulePayload } = data; + const chains = [...data.chains].reverse().join(" / "); + const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; + const host = metadata.host + ? `${metadata.host}:${metadata.destinationPort}` + : `${metadata.destinationIP}:${metadata.destinationPort}`; + + const information = [ + { label: "Host", value: host }, + { label: "Download", value: parseTraffic(data.download).join(" ") }, + { label: "Upload", value: parseTraffic(data.upload).join(" ") }, + { + label: "DL Speed", + value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s", + }, + { + label: "UL Speed", + value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s", + }, + { label: "Chains", value: chains }, + { label: "Rule", value: rule }, + { + label: "Process", + value: truncateStr(metadata.process || metadata.processPath), + }, + { label: "Time", value: dayjs(data.start).fromNow() }, + { label: "Source", value: `${metadata.sourceIP}:${metadata.sourcePort}` }, + { label: "Destination IP", value: metadata.destinationIP }, + { label: "Type", value: `${metadata.type}(${metadata.network})` }, + ]; + + const onDelete = useLockFn(async () => deleteConnection(data.id)); + + return ( + <Box sx={{ userSelect: "text" }}> + {information.map((each) => ( + <div key={each.label}> + <b>{each.label}</b>: <span>{each.value}</span> + </div> + ))} + + <Box sx={{ textAlign: "right" }}> + <Button + variant="contained" + title="Close Connection" + onClick={() => { + onDelete(); + onClose?.(); + }} + > + Close + </Button> + </Box> + </Box> + ); +}; diff --git a/src/components/connection/connection-item.tsx b/src/components/connection/connection-item.tsx index be2ea4844a6d223457d15e418a3f46957194156b..d04bf210e162a5dee3ebe1384b153d075a67d3a5 100644 --- a/src/components/connection/connection-item.tsx +++ b/src/components/connection/connection-item.tsx @@ -24,10 +24,11 @@ const Tag = styled("span")(({ theme }) => ({ interface Props { value: IConnectionsItem; + onShowDetail?: () => void; } -const ConnectionItem = (props: Props) => { - const { value } = props; +export const ConnectionItem = (props: Props) => { + const { value, onShowDetail } = props; const { id, metadata, chains, start, curUpload, curDownload } = value; @@ -44,8 +45,9 @@ const ConnectionItem = (props: Props) => { } > <ListItemText - sx={{ userSelect: "text" }} + sx={{ userSelect: "text", cursor: "pointer" }} primary={metadata.host || metadata.destinationIP} + onClick={onShowDetail} secondary={ <Box sx={{ display: "flex", flexWrap: "wrap" }}> <Tag sx={{ textTransform: "uppercase", color: "success" }}> @@ -71,5 +73,3 @@ const ConnectionItem = (props: Props) => { </ListItem> ); }; - -export default ConnectionItem; diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index 7d6696f581247c7c736900eadd02964b20d449c9..8f7b4b6c1a3ea8d96a99179e21a2b866240e70cf 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -1,37 +1,29 @@ import dayjs from "dayjs"; import { useMemo, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Snackbar } from "@mui/material"; +import { truncateStr } from "@/utils/truncate-str"; import parseTraffic from "@/utils/parse-traffic"; interface Props { connections: IConnectionsItem[]; + onShowDetail: (data: IConnectionsItem) => void; } -const ConnectionTable = (props: Props) => { - const { connections } = props; +export const ConnectionTable = (props: Props) => { + const { connections, onShowDetail } = props; - const [openedDetail, setOpenedDetail] = useState<IConnectionsItem | null>( - null - ); + const [columnVisible, setColumnVisible] = useState< + Partial<Record<keyof IConnectionsItem, boolean>> + >({}); const columns: GridColDef[] = [ - { - field: "host", - headerName: "Host", - flex: 200, - minWidth: 200, - resizable: false, - disableColumnMenu: true, - }, + { field: "host", headerName: "Host", flex: 220, minWidth: 220 }, { field: "download", headerName: "Download", width: 88, align: "right", headerAlign: "right", - disableColumnMenu: true, - valueFormatter: (params: any) => parseTraffic(params.value).join(" "), }, { field: "upload", @@ -39,18 +31,13 @@ const ConnectionTable = (props: Props) => { width: 88, align: "right", headerAlign: "right", - disableColumnMenu: true, - valueFormatter: (params: any) => parseTraffic(params.value).join(" "), }, { field: "dlSpeed", headerName: "DL Speed", - align: "right", width: 88, + align: "right", headerAlign: "right", - disableColumnMenu: true, - valueFormatter: (params: any) => - parseTraffic(params.value).join(" ") + "/s", }, { field: "ulSpeed", @@ -58,55 +45,26 @@ const ConnectionTable = (props: Props) => { width: 88, align: "right", headerAlign: "right", - disableColumnMenu: true, - valueFormatter: (params: any) => - parseTraffic(params.value).join(" ") + "/s", - }, - { - field: "chains", - headerName: "Chains", - width: 360, - disableColumnMenu: true, - }, - { - field: "rule", - headerName: "Rule", - width: 225, - disableColumnMenu: true, - }, - { - field: "process", - headerName: "Process", - width: 480, - disableColumnMenu: true, }, + { field: "chains", headerName: "Chains", flex: 360, minWidth: 360 }, + { field: "rule", headerName: "Rule", flex: 300, minWidth: 250 }, + { field: "process", headerName: "Process", flex: 480, minWidth: 480 }, { field: "time", headerName: "Time", - width: 120, + flex: 120, + minWidth: 100, align: "right", headerAlign: "right", - disableColumnMenu: true, - valueFormatter: (params) => dayjs(params.value).fromNow(), - }, - { - field: "source", - headerName: "Source", - width: 150, - disableColumnMenu: true, }, + { field: "source", headerName: "Source", flex: 200, minWidth: 130 }, { field: "destinationIP", headerName: "Destination IP", - width: 125, - disableColumnMenu: true, - }, - { - field: "type", - headerName: "Type", - width: 160, - disableColumnMenu: true, + flex: 200, + minWidth: 130, }, + { field: "type", headerName: "Type", flex: 160, minWidth: 100 }, ]; const connRows = useMemo(() => { @@ -120,18 +78,16 @@ const ConnectionTable = (props: Props) => { host: metadata.host ? `${metadata.host}:${metadata.destinationPort}` : `${metadata.destinationIP}:${metadata.destinationPort}`, - download: each.download, - upload: each.upload, - dlSpeed: each.curDownload, - ulSpeed: each.curUpload, + download: parseTraffic(each.download).join(" "), + upload: parseTraffic(each.upload).join(" "), + dlSpeed: parseTraffic(each.curDownload).join(" ") + "/s", + ulSpeed: parseTraffic(each.curUpload).join(" ") + "/s", chains, rule, - process: truncateStr( - metadata.process || metadata.processPath || "", - 16, - 56 + process: truncateStr(metadata.process || metadata.processPath)?.repeat( + 10 ), - time: each.start, + time: dayjs(each.start).fromNow(), source: `${metadata.sourceIP}:${metadata.sourcePort}`, destinationIP: metadata.destinationIP, type: `${metadata.type}(${metadata.network})`, @@ -142,101 +98,15 @@ const ConnectionTable = (props: Props) => { }, [connections]); return ( - <> - <DataGrid - rows={connRows} - columns={columns} - onRowClick={(e) => setOpenedDetail(e.row.connectionData)} - density="compact" - sx={{ border: "none", "div:focus": { outline: "none !important" } }} - hideFooter - /> - <Snackbar - anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - open={Boolean(openedDetail)} - onClose={() => setOpenedDetail(null)} - message={ - openedDetail ? <SingleConnectionDetail data={openedDetail} /> : null - } - /> - </> - ); -}; - -export default ConnectionTable; - -const truncateStr = (str: string, prefixLen: number, maxLen: number) => { - if (str.length <= maxLen) return str; - return ( - str.slice(0, prefixLen) + " ... " + str.slice(-(maxLen - prefixLen - 5)) - ); -}; - -const SingleConnectionDetail = ({ data }: { data: IConnectionsItem }) => { - const { metadata, rulePayload } = data; - const chains = [...data.chains].reverse().join(" / "); - const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; - const host = metadata.host - ? `${metadata.host}:${metadata.destinationPort}` - : `${metadata.destinationIP}:${metadata.destinationPort}`; - - return ( - <div> - <div> - {" "} - <b>Host</b>: <span>{host}</span>{" "} - </div> - <div> - {" "} - <b>Download</b>: <span>{parseTraffic(data.download).join(" ")}</span>{" "} - </div> - <div> - {" "} - <b>Upload</b>: <span>{parseTraffic(data.upload).join(" ")}</span>{" "} - </div> - <div> - {" "} - <b>DL Speed</b>:{" "} - <span>{parseTraffic(data.curDownload ?? -1).join(" ") + "/s"}</span>{" "} - </div> - <div> - {" "} - <b>UL Speed</b>:{" "} - <span>{parseTraffic(data.curUpload ?? -1).join(" ") + "/s"}</span>{" "} - </div> - <div> - {" "} - <b>Chains</b>: <span>{chains}</span>{" "} - </div> - <div> - {" "} - <b>Rule</b>: <span>{rule}</span>{" "} - </div> - <div> - {" "} - <b>Process</b>: <span>{metadata.process}</span>{" "} - </div> - <div> - {" "} - <b>ProcessPath</b>: <span>{metadata.processPath}</span>{" "} - </div> - <div> - {" "} - <b>Time</b>: <span>{dayjs(data.start).fromNow()}</span>{" "} - </div> - <div> - {" "} - <b>Source</b>:{" "} - <span>{`${metadata.sourceIP}:${metadata.sourcePort}`}</span>{" "} - </div> - <div> - {" "} - <b>Destination IP</b>: <span>{metadata.destinationIP}</span>{" "} - </div> - <div> - {" "} - <b>Type</b>: <span>{`${metadata.type}(${metadata.network})`}</span>{" "} - </div> - </div> + <DataGrid + hideFooter + rows={connRows} + columns={columns} + onRowClick={(e) => onShowDetail(e.row.connectionData)} + density="compact" + sx={{ border: "none", "div:focus": { outline: "none !important" } }} + columnVisibilityModel={columnVisible} + onColumnVisibilityModelChange={(e) => setColumnVisible(e)} + /> ); }; diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index 9e2bce53b22c60466217f0decd6b17fe4c1fd29e..1b5f2e723df115d29a73f24c6abe529f791423cc 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLockFn } from "ahooks"; import { Box, @@ -18,8 +18,12 @@ import { atomConnectionSetting } from "@/services/states"; import { useClashInfo } from "@/hooks/use-clash"; import { BaseEmpty, BasePage } from "@/components/base"; import { useWebsocket } from "@/hooks/use-websocket"; -import ConnectionItem from "@/components/connection/connection-item"; -import ConnectionTable from "@/components/connection/connection-table"; +import { ConnectionItem } from "@/components/connection/connection-item"; +import { ConnectionTable } from "@/components/connection/connection-table"; +import { + ConnectionDetail, + ConnectionDetailRef, +} from "@/components/connection/connection-detail"; const initConn = { uploadTotal: 0, downloadTotal: 0, connections: [] }; @@ -106,6 +110,8 @@ const ConnectionsPage = () => { const onCloseAll = useLockFn(closeAllConnections); + const detailRef = useRef<ConnectionDetailRef>(null!); + return ( <BasePage title={t("Connections")} @@ -186,14 +192,24 @@ const ConnectionsPage = () => { {filterConn.length === 0 ? ( <BaseEmpty text="No Connections" /> ) : isTableLayout ? ( - <ConnectionTable connections={filterConn} /> + <ConnectionTable + connections={filterConn} + onShowDetail={(detail) => detailRef.current?.open(detail)} + /> ) : ( <Virtuoso data={filterConn} - itemContent={(index, item) => <ConnectionItem value={item} />} + itemContent={(index, item) => ( + <ConnectionItem + value={item} + onShowDetail={() => detailRef.current?.open(item)} + /> + )} /> )} </Box> + + <ConnectionDetail ref={detailRef} /> </Paper> </BasePage> ); diff --git a/src/utils/parse-traffic.ts b/src/utils/parse-traffic.ts index 49afb6e8ca2ca6ee44640be0823e48ad74e71435..514d24fe2dc2c56b0c0f764876090bd93ff82df6 100644 --- a/src/utils/parse-traffic.ts +++ b/src/utils/parse-traffic.ts @@ -1,6 +1,7 @@ const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; -const parseTraffic = (num: number) => { +const parseTraffic = (num?: number) => { + if (typeof num !== "number") return ["NaN", ""]; if (num < 1000) return [`${Math.round(num)}`, "B"]; const exp = Math.min(Math.floor(Math.log2(num) / 10), UNITS.length - 1); const dat = num / Math.pow(1024, exp); diff --git a/src/utils/truncate-str.ts b/src/utils/truncate-str.ts new file mode 100644 index 0000000000000000000000000000000000000000..491fa07aefa74235efbb71b638462d57588e31d3 --- /dev/null +++ b/src/utils/truncate-str.ts @@ -0,0 +1,6 @@ +export const truncateStr = (str?: string, prefixLen = 16, maxLen = 56) => { + if (!str || str.length <= maxLen) return str; + return ( + str.slice(0, prefixLen) + " ... " + str.slice(-(maxLen - prefixLen - 5)) + ); +};