diff --git a/package.json b/package.json index 33e067e42f536fb4f18e3725f03a28d61cbaf230..700305fe184e0cfa5ff46fbff2ef9dbf81409116 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@emotion/styled": "^11.10.4", "@mui/icons-material": "^5.10.3", "@mui/material": "^5.10.3", + "@mui/x-data-grid": "^5.17.4", "@tauri-apps/api": "^1.1.0", "ahooks": "^3.7.0", "axios": "^0.27.2", diff --git a/src/components/connection/connection-item.tsx b/src/components/connection/connection-item.tsx index 01a909527dec92fdfa141e703ebc005dee51c65d..7f7e8235a77e51d972529cdc31efed59e7541c7a 100644 --- a/src/components/connection/connection-item.tsx +++ b/src/components/connection/connection-item.tsx @@ -1,19 +1,25 @@ import dayjs from "dayjs"; import { useLockFn } from "ahooks"; -import { styled, ListItem, IconButton, ListItemText } from "@mui/material"; +import { + styled, + ListItem, + IconButton, + ListItemText, + Box, + alpha, +} from "@mui/material"; import { CloseRounded } from "@mui/icons-material"; import { deleteConnection } from "@/services/api"; import parseTraffic from "@/utils/parse-traffic"; const Tag = styled("span")(({ theme }) => ({ - display: "inline-block", - fontSize: "12px", + fontSize: "10px", padding: "0 4px", lineHeight: 1.375, - border: "1px solid #ccc", + border: "1px solid", borderRadius: 4, - marginRight: "0.1em", - transform: "scale(0.92)", + borderColor: alpha(theme.palette.text.secondary, 0.35), + marginRight: "4px", })); interface Props { @@ -26,7 +32,7 @@ const ConnectionItem = (props: Props) => { const { id, metadata, chains, start, curUpload, curDownload } = value; const onDelete = useLockFn(async () => deleteConnection(id)); - const showTraffic = curUpload! > 1024 || curDownload! > 1024; + const showTraffic = curUpload! >= 100 || curDownload! >= 100; return ( <ListItem @@ -41,19 +47,17 @@ const ConnectionItem = (props: Props) => { sx={{ userSelect: "text" }} primary={metadata.host || metadata.destinationIP} secondary={ - <> + <Box sx={{ display: "flex", flexWrap: "wrap" }}> <Tag sx={{ textTransform: "uppercase", color: "success" }}> {metadata.network} </Tag> <Tag>{metadata.type}</Tag> - {metadata.process && <Tag>{metadata.process}</Tag>} + {!!metadata.process && <Tag>{metadata.process}</Tag>} {chains.length > 0 && <Tag>{chains[value.chains.length - 1]}</Tag>} - {chains.length > 0 && <Tag>{chains[0]}</Tag>} - <Tag>{dayjs(start).fromNow()}</Tag> {showTraffic && ( @@ -61,7 +65,7 @@ const ConnectionItem = (props: Props) => { {parseTraffic(curUpload!)} / {parseTraffic(curDownload!)} </Tag> )} - </> + </Box> } /> </ListItem> diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2111c8117d5ba145f3281de8b9e693f8fc46efdc --- /dev/null +++ b/src/components/connection/connection-table.tsx @@ -0,0 +1,144 @@ +import dayjs from "dayjs"; +import { useMemo } from "react"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import parseTraffic from "@/utils/parse-traffic"; + +interface Props { + connections: ApiType.ConnectionsItem[]; +} + +const ConnectionTable = (props: Props) => { + const { connections } = props; + + const columns: GridColDef[] = [ + { + field: "host", + headerName: "Host", + flex: 200, + minWidth: 200, + resizable: false, + disableColumnMenu: true, + }, + { + field: "download", + headerName: "Download", + width: 88, + align: "right", + headerAlign: "right", + disableColumnMenu: true, + valueFormatter: (params: any) => parseTraffic(params.value).join(" "), + }, + { + field: "upload", + headerName: "Upload", + width: 88, + align: "right", + headerAlign: "right", + disableColumnMenu: true, + valueFormatter: (params: any) => parseTraffic(params.value).join(" "), + }, + { + field: "dlSpeed", + headerName: "DL Speed", + align: "right", + width: 88, + headerAlign: "right", + disableColumnMenu: true, + valueFormatter: (params: any) => + parseTraffic(params.value).join(" ") + "/s", + }, + { + field: "ulSpeed", + headerName: "UL Speed", + 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: 120, + disableColumnMenu: true, + }, + { + field: "time", + headerName: "Time", + width: 120, + align: "right", + headerAlign: "right", + disableColumnMenu: true, + valueFormatter: (params) => dayjs(params.value).fromNow(), + }, + { + field: "source", + headerName: "Source", + width: 150, + disableColumnMenu: true, + }, + { + field: "destinationIP", + headerName: "Destination IP", + width: 125, + disableColumnMenu: true, + }, + { + field: "type", + headerName: "Type", + width: 160, + disableColumnMenu: true, + }, + ]; + + const connRows = useMemo(() => { + return connections.map((each) => { + const { metadata, rulePayload } = each; + const chains = [...each.chains].reverse().join(" / "); + const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule; + + return { + id: each.id, + host: metadata.host + ? `${metadata.host}:${metadata.destinationPort}` + : `${metadata.destinationIP}:${metadata.destinationPort}`, + download: each.download, + upload: each.upload, + dlSpeed: each.curDownload, + ulSpeed: each.curUpload, + chains, + rule, + process: metadata.process || metadata.processPath, + time: each.start, + source: `${metadata.sourceIP}:${metadata.sourcePort}`, + destinationIP: metadata.destinationIP, + type: `${metadata.type}(${metadata.network})`, + }; + }); + }, [connections]); + + return ( + <DataGrid + rows={connRows} + columns={columns} + density="compact" + sx={{ border: "none", "div:focus": { outline: "none !important" } }} + hideFooter + /> + ); +}; + +export default ConnectionTable; diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index ece2bfe0b81cc5b09abd2fc357058b1bcfc0bf44..f85c54bf8019b965b0dbd6d7409337f0e610ee4d 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -1,12 +1,24 @@ import { useEffect, useMemo, useState } from "react"; import { useLockFn } from "ahooks"; -import { Box, Button, MenuItem, Paper, Select, TextField } from "@mui/material"; +import { + Box, + Button, + IconButton, + MenuItem, + Paper, + Select, + TextField, +} from "@mui/material"; +import { useRecoilState } from "recoil"; import { Virtuoso } from "react-virtuoso"; import { useTranslation } from "react-i18next"; +import { TableChartRounded, TableRowsRounded } from "@mui/icons-material"; import { closeAllConnections, getInformation } from "@/services/api"; +import { atomConnectionSetting } from "@/services/states"; import BasePage from "@/components/base/base-page"; import BaseEmpty from "@/components/base/base-empty"; import ConnectionItem from "@/components/connection/connection-item"; +import ConnectionTable from "@/components/connection/connection-table"; const initConn = { uploadTotal: 0, downloadTotal: 0, connections: [] }; @@ -19,10 +31,12 @@ const ConnectionsPage = () => { const [curOrderOpt, setOrderOpt] = useState("Default"); const [connData, setConnData] = useState<ApiType.Connections>(initConn); + const [setting, setSetting] = useRecoilState(atomConnectionSetting); + + const isTableLayout = setting.layout === "table"; + const orderOpts: Record<string, OrderFunc> = { Default: (list) => list, - // "Download Traffic": (list) => list, - // "Upload Traffic": (list) => list, "Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!), "Download Speed": (list) => list.sort((a, b) => b.curDownload! - a.curDownload!), @@ -92,14 +106,29 @@ const ConnectionsPage = () => { title={t("Connections")} contentStyle={{ height: "100%" }} header={ - <Button - size="small" - sx={{ mt: 1 }} - variant="contained" - onClick={onCloseAll} - > - {t("Close All")} - </Button> + <Box sx={{ mt: 1, display: "flex", alignItems: "center" }}> + <IconButton + size="small" + sx={{ mr: 2 }} + onClick={() => + setSetting((o) => + o.layout === "list" + ? { ...o, layout: "table" } + : { ...o, layout: "list" } + ) + } + > + {isTableLayout ? ( + <TableChartRounded fontSize="inherit" /> + ) : ( + <TableRowsRounded fontSize="inherit" /> + )} + </IconButton> + + <Button size="small" variant="contained" onClick={onCloseAll}> + {t("Close All")} + </Button> + </Box> } > <Paper sx={{ boxShadow: 2, height: "100%" }}> @@ -113,23 +142,25 @@ const ConnectionsPage = () => { alignItems: "center", }} > - <Select - size="small" - autoComplete="off" - value={curOrderOpt} - onChange={(e) => setOrderOpt(e.target.value)} - sx={{ - mr: 1, - width: i18n.language === "en" ? 190 : 120, - '[role="button"]': { py: 0.65 }, - }} - > - {Object.keys(orderOpts).map((opt) => ( - <MenuItem key={opt} value={opt}> - <span style={{ fontSize: 14 }}>{t(opt)}</span> - </MenuItem> - ))} - </Select> + {!isTableLayout && ( + <Select + size="small" + autoComplete="off" + value={curOrderOpt} + onChange={(e) => setOrderOpt(e.target.value)} + sx={{ + mr: 1, + width: i18n.language === "en" ? 190 : 120, + '[role="button"]': { py: 0.65 }, + }} + > + {Object.keys(orderOpts).map((opt) => ( + <MenuItem key={opt} value={opt}> + <span style={{ fontSize: 14 }}>{t(opt)}</span> + </MenuItem> + ))} + </Select> + )} <TextField hiddenLabel @@ -145,13 +176,15 @@ const ConnectionsPage = () => { </Box> <Box height="calc(100% - 50px)"> - {filterConn.length > 0 ? ( + {filterConn.length === 0 ? ( + <BaseEmpty text="No Connections" /> + ) : isTableLayout ? ( + <ConnectionTable connections={filterConn} /> + ) : ( <Virtuoso data={filterConn} itemContent={(index, item) => <ConnectionItem value={item} />} /> - ) : ( - <BaseEmpty text="No Connections" /> )} </Box> </Paper> diff --git a/src/services/states.ts b/src/services/states.ts index 664fc3b9f6f922c86b2bddc1739b61a6dbadb5ac..48b66e8b09cbfd9b38bdd2c3021a0c53d8c150de 100644 --- a/src/services/states.ts +++ b/src/services/states.ts @@ -21,14 +21,45 @@ export const atomEnableLog = atom<boolean>({ ({ setSelf, onSet }) => { const key = "enable-log"; - setSelf(localStorage.getItem(key) !== "false"); + try { + setSelf(localStorage.getItem(key) !== "false"); + } catch {} onSet((newValue, _, isReset) => { - if (isReset) { - localStorage.removeItem(key); - } else { - localStorage.setItem(key, newValue.toString()); - } + try { + if (isReset) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, newValue.toString()); + } + } catch {} + }); + }, + ], +}); + +interface IConnectionSetting { + layout: "table" | "list"; +} + +export const atomConnectionSetting = atom<IConnectionSetting>({ + key: "atomConnectionSetting", + effects: [ + ({ setSelf, onSet }) => { + const key = "connections-setting"; + + try { + const value = localStorage.getItem(key); + const data = value == null ? { layout: "list" } : JSON.parse(value); + setSelf(data); + } catch { + setSelf({ layout: "list" }); + } + + onSet((newValue) => { + try { + localStorage.setItem(key, JSON.stringify(newValue)); + } catch {} }); }, ], diff --git a/src/services/types.d.ts b/src/services/types.d.ts index ab94609b9f898347a117c5dc24f620d4eb77c38b..7f8d868f66e85d2577ac155aab324002514799ce 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -68,6 +68,7 @@ declare namespace ApiType { destinationPort: string; destinationIP?: string; process?: string; + processPath?: string; }; upload: number; download: number; diff --git a/yarn.lock b/yarn.lock index 57e25d684ea2aab2f9de11b403d00403bcc73286..5f5c5b180a07e9f2ebb26a37574b87016fd3a9f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -552,6 +552,17 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/x-data-grid@^5.17.4": + version "5.17.4" + resolved "https://registry.yarnpkg.com/@mui/x-data-grid/-/x-data-grid-5.17.4.tgz#93ccd06a0a15d02b8d59c2d3038e217ffc72350d" + integrity sha512-cxZuu65Whh1DNU9M2X5ljDOx+GAEpGeJLPnugMjhgqTOszfJZX/4kI7NftrPy051Hy0um0sv0NVTDSFXG6yixA== + dependencies: + "@babel/runtime" "^7.18.9" + "@mui/utils" "^5.10.3" + clsx "^1.2.1" + prop-types "^15.8.1" + reselect "^4.1.6" + "@octokit/auth-token@^2.4.4": version "2.5.0" resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.5.0.tgz#27c37ea26c205f28443402477ffd261311f21e36" @@ -2016,6 +2027,11 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== +reselect@^4.1.6: + version "4.1.6" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.6.tgz#19ca2d3d0b35373a74dc1c98692cdaffb6602656" + integrity sha512-ZovIuXqto7elwnxyXbBtCPo9YFEr3uJqj2rRbcOOog1bmu2Ag85M4hixSwFWyaBMKXNgvPaJ9OSu9SkBPIeJHQ== + resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"