import './Connections.css'; import React from 'react'; import { Pause, Play, X as IconClose } from 'react-feather'; import { useTranslation } from 'react-i18next'; import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; import { ConnectionItem } from 'src/api/connections'; import { State } from 'src/store/types'; import * as connAPI from '../api/connections'; import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; import { getClashAPIConfig } from '../store/app'; import s from './Connections.module.scss'; import ConnectionTable from './ConnectionTable'; import ContentHeader from './ContentHeader'; import ModalCloseAllConnections from './ModalCloseAllConnections'; import { Action, Fab, position as fabPosition } from './shared/Fab'; import { connect } from './StateProvider'; import SvgYacd from './SvgYacd'; const { useEffect, useState, useRef, useCallback } = React; const paddingBottom = 30; function arrayToIdKv(items: T[]) { const o = {}; for (let i = 0; i < items.length; i++) { const item = items[i]; o[item.id] = item; } return o; } type FormattedConn = { id: string; upload: number; download: number; start: number; chains: string; rule: string; destinationPort: string; destinationIP: string; sourceIP: string; sourcePort: string; source: string; host: string; type: string; network: string; downloadSpeedCurr?: number; uploadSpeedCurr?: number; }; function hasSubstring(s: string, pat: string) { return s.toLowerCase().includes(pat.toLowerCase()); } function filterConns(conns: FormattedConn[], keyword: string) { return !keyword ? conns : conns.filter((conn) => [ conn.host, conn.sourceIP, conn.sourcePort, conn.destinationIP, conn.chains, conn.rule, conn.type, conn.network, ].some((field) => hasSubstring(field, keyword)) ); } function formatConnectionDataItem( i: ConnectionItem, prevKv: Record, now: number ): FormattedConn { const { id, metadata, upload, download, start, chains, rule, rulePayload } = i; const { host, destinationPort, destinationIP, network, type, sourceIP, sourcePort } = metadata; // host could be an empty string if it's direct IP connection let host2 = host; if (host2 === '') host2 = destinationIP; const prev = prevKv[id]; const ret = { id, upload, download, start: now - new Date(start).valueOf(), chains: chains.reverse().join(' / '), rule: !rulePayload ? rule : `${rule}(${rulePayload})`, ...metadata, host: `${host2}:${destinationPort}`, type: `${type}(${network})`, source: `${sourceIP}:${sourcePort}`, downloadSpeedCurr: download - (prev ? prev.download : 0), uploadSpeedCurr: upload - (prev ? prev.upload : 0), }; return ret; } function renderTableOrPlaceholder(conns: FormattedConn[]) { return conns.length > 0 ? ( ) : (
); } function ConnQty({ qty }) { return qty < 100 ? '' + qty : '99+'; } function Conn({ apiConfig }) { const [refContainer, containerHeight] = useRemainingViewPortHeight(); const [conns, setConns] = useState([]); const [closedConns, setClosedConns] = useState([]); const [filterKeyword, setFilterKeyword] = useState(''); const filteredConns = filterConns(conns, filterKeyword); const filteredClosedConns = filterConns(closedConns, filterKeyword); const [isCloseAllModalOpen, setIsCloseAllModalOpen] = useState(false); const openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []); const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []); const [isRefreshPaused, setIsRefreshPaused] = useState(false); const toggleIsRefreshPaused = useCallback(() => { setIsRefreshPaused((x) => !x); }, []); const closeAllConnections = useCallback(() => { connAPI.closeAllConnections(apiConfig); closeCloseAllModal(); }, [apiConfig, closeCloseAllModal]); const prevConnsRef = useRef(conns); const read = useCallback( ({ connections }) => { const prevConnsKv = arrayToIdKv(prevConnsRef.current); const now = Date.now(); const x = connections.map((c: ConnectionItem) => formatConnectionDataItem(c, prevConnsKv, now) ); const closed = []; for (const c of prevConnsRef.current) { const idx = x.findIndex((conn: ConnectionItem) => conn.id === c.id); if (idx < 0) closed.push(c); } setClosedConns((prev) => { // keep max 100 entries return [...closed, ...prev].slice(0, 101); }); // if previous connections and current connections are both empty // arrays, we wont update state to avaoid rerender if (x && (x.length !== 0 || prevConnsRef.current.length !== 0) && !isRefreshPaused) { prevConnsRef.current = x; setConns(x); } else { prevConnsRef.current = x; } }, [setConns, isRefreshPaused] ); useEffect(() => { return connAPI.fetchData(apiConfig, read); }, [apiConfig, read]); const { t } = useTranslation(); return (
{t('Active')} {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */} {t('Closed')} {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
setFilterKeyword(e.target.value)} />
<>{renderTableOrPlaceholder(filteredConns)} : } mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}} style={fabPosition} text={isRefreshPaused ? t('Resume Refresh') : t('Pause Refresh')} onClick={toggleIsRefreshPaused} > {renderTableOrPlaceholder(filteredClosedConns)}
); } const mapState = (s: State) => ({ apiConfig: getClashAPIConfig(s), }); export default connect(mapState)(Conn);