mirror of
https://github.com/kubeshark/kubeshark.git
synced 2025-08-29 13:54:52 +00:00
TRA-4602 Traffic statistics - Pie Chart (#1153)
* pie chart for protocols and methods by requests and volume * protocols legend * methods legend * pie chart component * code refactor * no message Co-authored-by: gadotroee <55343099+gadotroee@users.noreply.github.com>
This commit is contained in:
parent
6f117d0a84
commit
c9d4f88de8
@ -60,6 +60,7 @@
|
||||
"react-scrollable-feed-virtualized": "^1.4.9",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-toastify": "^8.2.0",
|
||||
"recharts": "^2.1.10",
|
||||
"redoc": "^2.0.0-rc.71",
|
||||
"styled-components": "^5.3.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
|
@ -197,14 +197,14 @@ export const ServiceMapModal: React.FC<ServiceMapModalProps> = ({ isOpen, onClos
|
||||
<Fade in={isOpen}>
|
||||
<Box sx={modalStyle}>
|
||||
<div className={styles.closeIcon}>
|
||||
<img src={closeIcon} alt="close" onClick={() => onClose()} style={{ cursor: "pointer", userSelect: "none" }}></img>
|
||||
<img src={closeIcon} alt="close" onClick={() => onClose()} style={{ cursor: "pointer", userSelect: "none" }}/>
|
||||
</div>
|
||||
<div className={styles.headerContainer}>
|
||||
<div className={styles.headerSection}>
|
||||
<span className={styles.title}>Services</span>
|
||||
<Button size="medium"
|
||||
variant="contained"
|
||||
startIcon={<img src={isFilterClicked ? filterIconClicked : filterIcon} className="custom" alt="refresh" style={{ height: "26px", width: "26px" }}></img>}
|
||||
startIcon={<img src={isFilterClicked ? filterIconClicked : filterIcon} className="custom" alt="refresh" style={{ height: "26px", width: "26px" }}/>}
|
||||
className={commonClasses.outlinedButton + " " + commonClasses.imagedButton + ` ${isFilterClicked ? commonClasses.clickedButton : ""}`}
|
||||
onClick={() => setIsFilterClicked(prevState => !prevState)}
|
||||
style={{ textTransform: 'unset' }}>
|
||||
|
@ -0,0 +1,16 @@
|
||||
.breadCrumbsContainer
|
||||
margin-top: 15px
|
||||
height: 15px
|
||||
|
||||
.breadCrumbs
|
||||
color: #494677
|
||||
text-align: left
|
||||
|
||||
.clickableTag
|
||||
margin-right: 5px
|
||||
border-bottom: 1px black solid
|
||||
cursor: pointer
|
||||
|
||||
.nonClickableTag
|
||||
margin-left: 5px
|
||||
font-weight: 600
|
@ -0,0 +1,131 @@
|
||||
import React, {useEffect, useMemo, useState} from "react";
|
||||
import styles from "./TrafficPieChart.module.sass";
|
||||
import {Cell, Legend, Pie, PieChart, Tooltip} from "recharts";
|
||||
import {Utils} from "../../../../helpers/Utils";
|
||||
|
||||
enum PieChartMode {
|
||||
REQUESTS = "entriesCount",
|
||||
VOLUME = "volumeSizeBytes"
|
||||
}
|
||||
|
||||
const COLORS = ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#808080', '#ffffff', '#000000'];
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderCustomizedLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
index
|
||||
}: any) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
interface TrafficPieChartProps {
|
||||
pieChartMode: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export const TrafficPieChart: React.FC<TrafficPieChartProps> = ({pieChartMode , data}) => {
|
||||
|
||||
const [protocolsStats, setProtocolsStats] = useState([]);
|
||||
const [commandStats, setCommandStats] = useState(null);
|
||||
const [selectedProtocol, setSelectedProtocol] = useState(null as string);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const protocolsPieData = data.map(protocol => {
|
||||
return {
|
||||
name: protocol.name,
|
||||
value: protocol[PieChartMode[pieChartMode]],
|
||||
color: protocol.color
|
||||
}
|
||||
})
|
||||
setProtocolsStats(protocolsPieData)
|
||||
}, [data, pieChartMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProtocol) {
|
||||
setCommandStats(null);
|
||||
return;
|
||||
}
|
||||
const commandsPieData = data.find(protocol => protocol.name === selectedProtocol).methods.map(command => {
|
||||
return {
|
||||
name: command.name,
|
||||
value: command[PieChartMode[pieChartMode]]
|
||||
}
|
||||
})
|
||||
setCommandStats(commandsPieData);
|
||||
}, [selectedProtocol, pieChartMode, data])
|
||||
|
||||
const pieLegend = useMemo(() => {
|
||||
if (!data) return;
|
||||
let legend;
|
||||
if (!selectedProtocol) {
|
||||
legend = data.map(protocol => <div style={{marginBottom: 5, display: "flex"}}>
|
||||
<div style={{height: 15, width: 30, background: protocol?.color}}/>
|
||||
<span style={{marginLeft: 5}}>
|
||||
{protocol.name}
|
||||
</span>
|
||||
</div>)
|
||||
} else {
|
||||
legend = data.find(protocol => protocol.name === selectedProtocol).methods.map((method, index) => <div
|
||||
style={{marginBottom: 5, display: "flex"}}>
|
||||
<div style={{height: 15, width: 30, background: COLORS[index % COLORS.length]}}/>
|
||||
<span style={{marginLeft: 5}}>
|
||||
{method.name}
|
||||
</span>
|
||||
</div>)
|
||||
}
|
||||
return <div>{legend}</div>;
|
||||
}, [data, selectedProtocol])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.breadCrumbsContainer}>
|
||||
{selectedProtocol && <div className={styles.breadCrumbs}>
|
||||
<span className={styles.clickableTag} onClick={() => setSelectedProtocol(null)}>protocols</span>
|
||||
<span>/</span>
|
||||
<span className={styles.nonClickableTag}>{selectedProtocol}</span>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{protocolsStats?.length > 0 && <div style={{width: "100%", display: "flex", justifyContent: "center"}}>
|
||||
<PieChart width={300} height={300}>
|
||||
<Pie
|
||||
data={commandStats || protocolsStats}
|
||||
dataKey="value"
|
||||
cx={150}
|
||||
cy={125}
|
||||
labelLine={false}
|
||||
label={renderCustomizedLabel}
|
||||
outerRadius={125}
|
||||
fill="#8884d8"
|
||||
onClick={(section) => !commandStats && setSelectedProtocol(section.name)}>
|
||||
{(commandStats || protocolsStats).map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color || COLORS[index % COLORS.length]}/>)
|
||||
)}
|
||||
</Pie>
|
||||
<Legend wrapperStyle={{position: "absolute", width: "auto", height: "auto", right: -150, top: 0}} content={pieLegend}/>
|
||||
<Tooltip formatter={(value) => pieChartMode === "VOLUME" ? Utils.humanFileSize(value) : value + " Requests"}/>
|
||||
</PieChart>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
.title
|
||||
color: #494677
|
||||
font-family: Source Sans Pro,Lucida Grande,Tahoma,sans-serif
|
||||
font-size: 28px
|
||||
font-weight: 600
|
||||
|
||||
.closeIcon
|
||||
position: absolute
|
||||
right: 20px
|
||||
top: 20px
|
||||
|
||||
.mainContainer
|
||||
padding: 30px
|
||||
text-align: center
|
||||
|
||||
.select
|
||||
border: none
|
||||
border-bottom: 1px black solid
|
||||
outline: none
|
@ -0,0 +1,86 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Backdrop, Box, Fade, Modal} from "@mui/material";
|
||||
import styles from "./TrafficStatsModal.module.sass";
|
||||
import closeIcon from "assets/close.svg";
|
||||
import {TrafficPieChart} from "./TrafficPieChart/TrafficPieChart";
|
||||
import spinnerImg from "assets/spinner.svg";
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute',
|
||||
top: '6%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, 0%)',
|
||||
width: '50vw',
|
||||
height: '82vh',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: '5px',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
color: '#000',
|
||||
};
|
||||
|
||||
enum StatsMode {
|
||||
REQUESTS = "entriesCount",
|
||||
VOLUME = "volumeSizeBytes"
|
||||
}
|
||||
|
||||
interface TrafficStatsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
getTrafficStatsDataApi: () => Promise<any>
|
||||
}
|
||||
|
||||
export const TrafficStatsModal: React.FC<TrafficStatsModalProps> = ({ isOpen, onClose, getTrafficStatsDataApi }) => {
|
||||
|
||||
const modes = Object.keys(StatsMode).filter(x => !(parseInt(x) >= 0));
|
||||
const [statsMode, setStatsMode] = useState(modes[0]);
|
||||
const [statsData, setStatsData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if(isOpen && getTrafficStatsDataApi) {
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getTrafficStatsDataApi();
|
||||
setStatsData(data);
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [isOpen, getTrafficStatsDataApi])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
aria-labelledby="transition-modal-title"
|
||||
aria-describedby="transition-modal-description"
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
closeAfterTransition
|
||||
BackdropComponent={Backdrop}
|
||||
BackdropProps={{ timeout: 500 }}>
|
||||
<Fade in={isOpen}>
|
||||
<Box sx={modalStyle}>
|
||||
<div className={styles.closeIcon}>
|
||||
<img src={closeIcon} alt="close" onClick={() => onClose()} style={{ cursor: "pointer", userSelect: "none" }}/>
|
||||
</div>
|
||||
<div className={styles.title}>Traffic Statistics</div>
|
||||
<div className={styles.mainContainer}>
|
||||
<div>
|
||||
<span style={{marginRight: 15}}>Breakdown By</span>
|
||||
<select className={styles.select} value={statsMode} onChange={(e) => setStatsMode(e.target.value)}>
|
||||
{modes.map(mode => <option value={mode}>{mode}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{isLoading ? <div style={{textAlign: "center", marginTop: 20}}>
|
||||
<img alt="spinner" src={spinnerImg} style={{ height: 50 }} />
|
||||
</div> : <TrafficPieChart pieChartMode={statsMode} data={statsData}/>}
|
||||
</div>
|
||||
</Box>
|
||||
</Fade>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.591 9.99997C18.591 14.7446 14.7447 18.5909 10.0001 18.5909C5.25546 18.5909 1.40918 14.7446 1.40918 9.99997C1.40918 5.25534 5.25546 1.40906 10.0001 1.40906C14.7447 1.40906 18.591 5.25534 18.591 9.99997Z" fill="#E9EBF8" stroke="#BCCEFD"/>
|
||||
<path d="M13.1604 8.23038L11.95 7.01994L10.1392 8.83078L8.32832 7.01994L7.11789 8.23038L8.92872 10.0412L7.12046 11.8495L8.33089 13.0599L10.1392 11.2517L11.9474 13.0599L13.1579 11.8495L11.3496 10.0412L13.1604 8.23038Z" fill="#205CF5"/>
|
||||
</svg>
|
After Width: | Height: | Size: 588 B |
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: none; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
|
||||
<circle cx="50" cy="50" fill="none" stroke="#1d3f72" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138" transform="rotate(275.903 50 50)">
|
||||
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"></animateTransform>
|
||||
</circle>
|
||||
<!-- [ldio] generated by https://loading.io/ --></svg>
|
After Width: | Height: | Size: 673 B |
@ -4,4 +4,26 @@ const IP_ADDRESS_REGEX = /([0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})(:([0-9]{
|
||||
export class Utils {
|
||||
static isIpAddress = (address: string): boolean => IP_ADDRESS_REGEX.test(address)
|
||||
static lineNumbersInString = (code:string): number => code.split("\n").length;
|
||||
|
||||
static humanFileSize(bytes, si=false, dp=1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
const r = 10**dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u];
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,9 @@ import { StatusBar } from './components/UI';
|
||||
import useWS, { DEFAULT_LEFTOFF } from './hooks/useWS';
|
||||
import OasModal from './components/modals/OasModal/OasModal';
|
||||
import { ServiceMapModal } from './components/modals/ServiceMapModal/ServiceMapModal';
|
||||
import { TrafficStatsModal } from './components/modals/TrafficStatsModal/TrafficStatsModal';
|
||||
|
||||
export { CodeEditorWrap as QueryForm } from './components/Filters/Filters';
|
||||
export { UI, StatusBar, OasModal, ServiceMapModal }
|
||||
export { UI, StatusBar, OasModal, ServiceMapModal, TrafficStatsModal }
|
||||
export { useWS, DEFAULT_LEFTOFF }
|
||||
export default TrafficViewer;
|
||||
|
@ -5,9 +5,11 @@ import { ServiceMapModal } from '@up9/mizu-common';
|
||||
import { useRecoilState } from "recoil";
|
||||
import serviceMapModalOpenAtom from "./recoil/serviceMapModalOpen";
|
||||
import oasModalOpenAtom from './recoil/oasModalOpen/atom';
|
||||
import trafficStatsModalOpenAtom from "./recoil/trafficStatsModalOpen";
|
||||
import { OasModal } from '@up9/mizu-common';
|
||||
import Api from './helpers/api';
|
||||
import { ThemeProvider, StyledEngineProvider, createTheme } from '@mui/material';
|
||||
import {ThemeProvider, StyledEngineProvider, createTheme} from '@mui/material';
|
||||
import { TrafficStatsModal } from '@up9/mizu-common';
|
||||
|
||||
const api = Api.getInstance()
|
||||
|
||||
@ -15,6 +17,7 @@ const App = () => {
|
||||
|
||||
const [serviceMapModalOpen, setServiceMapModalOpen] = useRecoilState(serviceMapModalOpenAtom);
|
||||
const [oasModalOpen, setOasModalOpen] = useRecoilState(oasModalOpenAtom)
|
||||
const [trafficStatsModalOpen, setTrafficStatsModalOpen] = useRecoilState(trafficStatsModalOpenAtom);
|
||||
|
||||
return (
|
||||
<StyledEngineProvider injectFirst>
|
||||
@ -33,6 +36,7 @@ const App = () => {
|
||||
openModal={oasModalOpen}
|
||||
handleCloseModal={() => setOasModalOpen(false)}
|
||||
/>}
|
||||
<TrafficStatsModal isOpen={trafficStatsModalOpen} onClose={() => setTrafficStatsModalOpen(false)} getTrafficStatsDataApi={api.getStats}/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
|
@ -10,6 +10,8 @@ import "@up9/mizu-common/dist/index.css"
|
||||
import oasModalOpenAtom from "../../../recoil/oasModalOpen/atom";
|
||||
import serviceMap from "../../assets/serviceMap.svg";
|
||||
import services from "../../assets/services.svg";
|
||||
import trafficStatsIcon from "../../assets/trafficStats.svg";
|
||||
import trafficStatsModalOpenAtom from "../../../recoil/trafficStatsModalOpen";
|
||||
|
||||
const api = Api.getInstance();
|
||||
|
||||
@ -17,6 +19,7 @@ export const TrafficPage: React.FC = () => {
|
||||
const commonClasses = useCommonStyles();
|
||||
const [serviceMapModalOpen, setServiceMapModalOpen] = useRecoilState(serviceMapModalOpenAtom);
|
||||
const [openOasModal, setOpenOasModal] = useRecoilState(oasModalOpenAtom);
|
||||
const [trafficStatsModalOpen, setTrafficStatsModalOpen] = useRecoilState(trafficStatsModalOpenAtom);
|
||||
const [shouldCloseWebSocket, setShouldCloseWebSocket] = useState(false);
|
||||
|
||||
const trafficViewerApi = { ...api }
|
||||
@ -26,13 +29,17 @@ export const TrafficPage: React.FC = () => {
|
||||
setOpenOasModal(true);
|
||||
}
|
||||
|
||||
const handleOpenStatsModal = () => {
|
||||
setShouldCloseWebSocket(true)
|
||||
setTrafficStatsModalOpen(true);
|
||||
}
|
||||
|
||||
const openServiceMapModalDebounce = debounce(() => {
|
||||
setShouldCloseWebSocket(true)
|
||||
setServiceMapModalOpen(true)
|
||||
}, 500);
|
||||
|
||||
const actionButtons = (window["isOasEnabled"] || window["isServiceMapEnabled"]) &&
|
||||
<div style={{ display: 'flex', height: "100%" }}>
|
||||
const actionButtons = <div style={{ display: 'flex', height: "100%" }}>
|
||||
{window["isOasEnabled"] && <Button
|
||||
startIcon={<img className="custom" src={services} alt="services" />}
|
||||
size="large"
|
||||
@ -48,15 +55,24 @@ export const TrafficPage: React.FC = () => {
|
||||
variant="contained"
|
||||
className={commonClasses.outlinedButton + " " + commonClasses.imagedButton}
|
||||
onClick={openServiceMapModalDebounce}
|
||||
style={{ textTransform: 'unset' }}>
|
||||
style={{ marginRight: 25, textTransform: 'unset' }}>
|
||||
Service Map
|
||||
</Button>}
|
||||
<Button
|
||||
startIcon={<img className="custom" src={trafficStatsIcon} alt="services" />}
|
||||
size="large"
|
||||
variant="contained"
|
||||
className={commonClasses.outlinedButton + " " + commonClasses.imagedButton}
|
||||
style={{ textTransform: 'unset' }}
|
||||
onClick={handleOpenStatsModal}>
|
||||
Traffic Stats
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrafficViewer webSocketUrl={MizuWebsocketURL} shouldCloseWebSocket={shouldCloseWebSocket} setShouldCloseWebSocket={setShouldCloseWebSocket}
|
||||
trafficViewerApiProp={trafficViewerApi} actionButtons={actionButtons} isShowStatusBar={!(openOasModal || serviceMapModalOpen)} isDemoBannerView={false} />
|
||||
trafficViewerApiProp={trafficViewerApi} actionButtons={actionButtons} isShowStatusBar={!(openOasModal || serviceMapModalOpen || trafficStatsModalOpen)} isDemoBannerView={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
10
ui/src/components/assets/trafficStats.svg
Normal file
10
ui/src/components/assets/trafficStats.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="22" height="16" viewBox="0 0 22 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="9" height="12" rx="1.5" stroke="#A0B2FF"/>
|
||||
<rect x="5" y="4" width="3.42857" height="2" rx="1" fill="#205CF5"/>
|
||||
<rect x="3" y="7" width="6" height="2" rx="1" fill="#205CF5"/>
|
||||
<rect x="3" y="11" width="6" height="2" rx="1" fill="#205CF5"/>
|
||||
<rect x="-0.5" y="0.5" width="9" height="12" rx="1.5" transform="matrix(-1 0 0 1 19 2)" stroke="#A0B2FF"/>
|
||||
<rect width="3" height="2" rx="1" transform="matrix(-1 0 0 1 15 4)" fill="#205CF5"/>
|
||||
<rect width="6" height="2" rx="1" transform="matrix(-1 0 0 1 18 7)" fill="#205CF5"/>
|
||||
<rect width="6" height="2" rx="1" transform="matrix(-1 0 0 1 18 11)" fill="#205CF5"/>
|
||||
</svg>
|
After Width: | Height: | Size: 734 B |
@ -110,4 +110,9 @@ export default class Api {
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
getStats = async () => {
|
||||
const response = await client.get("/status/accumulative");
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
8
ui/src/recoil/trafficStatsModalOpen/atom.ts
Normal file
8
ui/src/recoil/trafficStatsModalOpen/atom.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { atom } from "recoil"
|
||||
|
||||
const trafficStatsModalOpenAtom = atom({
|
||||
key: "trafficStatsModalOpenAtom",
|
||||
default: false
|
||||
})
|
||||
|
||||
export default trafficStatsModalOpenAtom;
|
2
ui/src/recoil/trafficStatsModalOpen/index.ts
Normal file
2
ui/src/recoil/trafficStatsModalOpen/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import atom from "./atom";
|
||||
export default atom;
|
Loading…
Reference in New Issue
Block a user