summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorLarvan2 <[email protected]>2026-01-01 15:52:18 +0800
committerLarvan2 <[email protected]>2026-01-01 15:52:18 +0800
commita547b3c8dc48791c4f767da0314d907eba543cc1 (patch)
tree70a0d500147dcb972574e9a9641da956e033a963 /src/components
parent6768024fc9460f7f5a459de32de4cf771c75e19c (diff)
chore: adjust style
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Collapsible.tsx58
-rw-r--r--src/components/CollapsibleSectionHeader.module.scss1
-rw-r--r--src/components/Config.module.scss26
-rw-r--r--src/components/Config.tsx2
-rw-r--r--src/components/ConnectionCard.module.scss26
-rw-r--r--src/components/ConnectionCard.tsx6
-rw-r--r--src/components/ConnectionTable.module.scss72
-rw-r--r--src/components/ConnectionTable.scss30
-rw-r--r--src/components/ConnectionTable.tsx392
-rw-r--r--src/components/Connections.module.scss70
-rw-r--r--src/components/Connections.tsx232
-rw-r--r--src/components/ContentHeader.module.scss21
-rw-r--r--src/components/ContentHeader.tsx10
-rw-r--r--src/components/Home.tsx4
-rw-r--r--src/components/Logs.module.scss173
-rw-r--r--src/components/Logs.tsx86
-rw-r--r--src/components/Rule.module.scss33
-rw-r--r--src/components/Rules.module.scss95
-rw-r--r--src/components/Rules.tsx79
-rw-r--r--src/components/Search.tsx4
-rw-r--r--src/components/proxies/Proxies.module.scss87
-rw-r--r--src/components/proxies/Proxies.tsx137
-rw-r--r--src/components/proxies/Proxy.module.scss21
-rw-r--r--src/components/proxies/Proxy.tsx38
-rw-r--r--src/components/proxies/ProxyGroup.module.scss31
-rw-r--r--src/components/proxies/ProxyGroup.tsx107
-rw-r--r--src/components/proxies/ProxyLatency.module.scss2
-rw-r--r--src/components/proxies/ProxyProvider.module.scss12
-rw-r--r--src/components/proxies/ProxyProvider.tsx1
-rw-r--r--src/components/proxies/ProxyProviderList.tsx32
-rw-r--r--src/components/proxies/Settings.tsx19
-rw-r--r--src/components/rules/RuleProviderItem.module.scss27
32 files changed, 1287 insertions, 647 deletions
diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx
index 47b882c..f43dbd6 100644
--- a/src/components/Collapsible.tsx
+++ b/src/components/Collapsible.tsx
@@ -1,38 +1,22 @@
import React from 'react';
-import ResizeObserver from 'resize-observer-polyfill';
import { framerMotionResouce } from '../misc/motion';
-const { memo, useState, useRef, useEffect } = React;
-
-function usePrevious(value) {
- const ref = useRef();
- useEffect(() => void (ref.current = value), [value]);
- return ref.current;
-}
-
-function useMeasure() {
- const ref = useRef();
- const [bounds, set] = useState({ height: 0 });
- useEffect(() => {
- const ro = new ResizeObserver(([entry]) => set(entry.contentRect));
- if (ref.current) ro.observe(ref.current);
- return () => ro.disconnect();
- }, []);
- return [ref, bounds];
-}
+const { memo } = React;
const variantsCollpapsibleWrap = {
initialOpen: {
height: 'auto',
+ opacity: 1,
+ visibility: 'visible',
transition: { duration: 0 },
},
- open: (height) => ({
- height,
+ open: {
+ height: 'auto',
opacity: 1,
visibility: 'visible',
transition: { duration: 0.3 },
- }),
+ },
closed: {
height: 0,
opacity: 0,
@@ -42,30 +26,18 @@ const variantsCollpapsibleWrap = {
},
};
-const variantsCollpapsibleChildContainer = {
- open: {},
- closed: {},
-};
-
-// @ts-expect-error ts-migrate(2339) FIXME: Property 'isOpen' does not exist on type '{ childr... Remove this comment to see the full error message
-const Collapsible = memo(({ children, isOpen }) => {
+const Collapsible = memo(({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => {
const module = framerMotionResouce.read();
const motion = module.motion;
- const previous = usePrevious(isOpen);
- // @ts-expect-error ts-migrate(2339) FIXME: Property 'height' does not exist on type 'MutableR... Remove this comment to see the full error message
- const [refToMeature, { height }] = useMeasure();
return (
- <div>
- <motion.div
- animate={isOpen && previous === isOpen ? 'initialOpen' : isOpen ? 'open' : 'closed'}
- custom={height}
- variants={variantsCollpapsibleWrap}
- >
- <motion.div variants={variantsCollpapsibleChildContainer} ref={refToMeature}>
- {children}
- </motion.div>
- </motion.div>
- </div>
+ <motion.div
+ initial={isOpen ? 'initialOpen' : 'closed'}
+ animate={isOpen ? 'open' : 'closed'}
+ variants={variantsCollpapsibleWrap}
+ style={{ overflow: 'hidden' }}
+ >
+ {children}
+ </motion.div>
);
});
diff --git a/src/components/CollapsibleSectionHeader.module.scss b/src/components/CollapsibleSectionHeader.module.scss
index a875719..f0797dd 100644
--- a/src/components/CollapsibleSectionHeader.module.scss
+++ b/src/components/CollapsibleSectionHeader.module.scss
@@ -2,6 +2,7 @@
display: flex;
align-items: center;
padding: 5px;
+ user-select: none;
&:focus {
outline: none;
diff --git a/src/components/Config.module.scss b/src/components/Config.module.scss
index 7d7083c..9bc3f08 100644
--- a/src/components/Config.module.scss
+++ b/src/components/Config.module.scss
@@ -7,6 +7,10 @@
display: flex;
flex-direction: column;
gap: 24px;
+
+ @media (max-width: 768px) {
+ padding: 15px 10px;
+ }
}
.section {
@@ -14,6 +18,10 @@
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
align-items: start;
+
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ }
}
.wrapSwitch {
@@ -29,7 +37,7 @@
padding: 0 40px;
}
> div {
- border-top: 1px dashed #373737;
+ border-top: 1px dashed var(--color-separator);
}
}
@@ -49,20 +57,20 @@
display: flex;
align-items: center;
gap: 10px;
- color: var(--color-text-main);
+ color: var(--color-text-highlight);
}
.card {
- background: var(--bg-near-transparent, rgba(255, 255, 255, 0.03));
- border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
- border-radius: 16px;
+ background: var(--bg-log-info-card);
+ border: 1px solid var(--color-separator);
+ border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
- backdrop-filter: blur(10px);
- transition: transform 0.2s ease, box-shadow 0.2s ease;
+ box-shadow: var(--shadow-card);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
- border-color: var(--border-color-hover, rgba(255, 255, 255, 0.15));
- box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
+ border-color: var(--color-focus-blue);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
}
diff --git a/src/components/Config.tsx b/src/components/Config.tsx
index 8319485..248f29c 100644
--- a/src/components/Config.tsx
+++ b/src/components/Config.tsx
@@ -256,7 +256,7 @@ function ConfigImpl({
return (
<div>
- <ContentHeader title={t('Config')} />
+ <ContentHeader />
<div className={s0.root}>
<div className={s0.card}>
<div className={s0.sectionTitle}>
diff --git a/src/components/ConnectionCard.module.scss b/src/components/ConnectionCard.module.scss
index 6007c45..e0c42c3 100644
--- a/src/components/ConnectionCard.module.scss
+++ b/src/components/ConnectionCard.module.scss
@@ -1,12 +1,12 @@
.card {
background: var(--color-bg-card);
border-radius: 12px;
- padding: 12px 16px;
- margin-bottom: 10px;
+ padding: 10px 14px;
+ margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
- gap: 6px;
+ gap: 4px;
cursor: pointer;
transition: transform 0.1s ease;
@@ -25,7 +25,7 @@
.host {
color: #40c4aa; // Similar to the image
font-weight: 600;
- font-size: 1rem;
+ font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -35,20 +35,20 @@
.time {
color: var(--color-text-secondary);
- font-size: 0.85rem;
+ font-size: 0.8rem;
flex-shrink: 0;
}
.typeProtocol {
color: var(--color-text);
- font-size: 0.9rem;
+ font-size: 0.85rem;
opacity: 0.8;
}
.totals {
display: flex;
- gap: 12px;
- font-size: 0.85rem;
+ gap: 10px;
+ font-size: 0.8rem;
color: var(--color-text-secondary);
span {
@@ -67,7 +67,7 @@
align-items: center;
gap: 6px;
color: var(--color-text-secondary);
- font-size: 0.85rem;
+ font-size: 0.8rem;
overflow: hidden;
flex: 1;
margin-right: 8px;
@@ -80,7 +80,7 @@
.arrow {
opacity: 0.5;
- font-size: 0.75rem;
+ font-size: 0.7rem;
}
.chains {
@@ -96,14 +96,14 @@
.speedAndAction {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 8px;
}
.speed {
display: flex;
align-items: center;
- gap: 6px;
- font-size: 0.9rem;
+ gap: 4px;
+ font-size: 0.8rem;
color: var(--color-text-secondary);
font-family: 'Roboto Mono', monospace;
diff --git a/src/components/ConnectionCard.tsx b/src/components/ConnectionCard.tsx
index 68ca63e..dc1bfb4 100644
--- a/src/components/ConnectionCard.tsx
+++ b/src/components/ConnectionCard.tsx
@@ -15,7 +15,7 @@ interface Props {
onClick: () => void;
}
-export default function ConnectionCard({ conn, onDisconnect, onClick }: Props) {
+const ConnectionCard = React.memo(function ConnectionCard({ conn, onDisconnect, onClick }: Props) {
const { i18n } = useTranslation();
let locale: Locale;
@@ -81,4 +81,6 @@ export default function ConnectionCard({ conn, onDisconnect, onClick }: Props) {
</div>
</div>
);
-}
+});
+
+export default ConnectionCard;
diff --git a/src/components/ConnectionTable.module.scss b/src/components/ConnectionTable.module.scss
index 1a47c81..e8e3daf 100644
--- a/src/components/ConnectionTable.module.scss
+++ b/src/components/ConnectionTable.module.scss
@@ -1,6 +1,10 @@
.tr {
transition: all 0.2s ease;
cursor: pointer;
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ min-width: fit-content;
&:hover {
// hover 作用到每个 td,避免 odd 行背景覆盖
@@ -16,25 +20,49 @@
.th {
height: 48px;
- background: var(--color-background);
+ background: var(--bg-log-info-card);
top: 0;
- font-size: 0.85em;
+ font-size: 0.8em;
font-weight: 600;
user-select: none;
- text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
- border-bottom: 2px solid var(--bg-near-transparent);
+ border-bottom: 2px solid var(--color-separator);
position: sticky;
z-index: 20;
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
+ padding: 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ flex-shrink: 0; // Ensure fixed width is respected
&:hover {
color: var(--color-text-highlight);
}
}
+.headerText {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.cellText {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+}
+
+.sortIconContainer {
+ margin-left: 4px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+}
+
+.rotate180 {
+ transform: rotate(180deg);
+}
+
.btnSection {
button {
margin-right: 15px;
@@ -49,15 +77,15 @@
}
.td {
- padding: 12px 8px;
+ padding: 0 8px;
font-size: 0.875em;
- min-width: 9em;
cursor: pointer;
- text-align: left;
vertical-align: middle;
white-space: nowrap;
- border-bottom: 1px solid var(--bg-near-transparent);
+ border-bottom: 1px solid var(--color-separator);
transition: color 0.15s ease;
+ box-sizing: border-box;
+ flex-shrink: 0; // Ensure fixed width is respected
&:hover {
color: var(--color-text-highlight);
@@ -78,7 +106,7 @@
border-collapse: separate;
border-spacing: 0;
width: 100%;
- background: var(--color-background);
+ background: transparent;
@media (max-width: 768px) {
display: none;
@@ -86,11 +114,11 @@
}
.tableWrapper {
- margin-top: 6px;
+ margin-top: 0;
border-radius: 12px;
- overflow: visible; // sticky 需要避免中间层成为 scroll container
- background: var(--color-background);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ overflow: hidden;
+ background: transparent;
+ box-shadow: none;
@media (max-width: 768px) {
background: transparent;
@@ -98,6 +126,14 @@
}
}
+.theadWrapper {
+ width: 100%;
+ overflow: hidden;
+ background: var(--bg-log-info-card);
+ border-top-left-radius: 12px;
+ border-top-right-radius: 12px;
+}
+
.cardsView {
display: none;
@@ -112,7 +148,7 @@
align-items: center;
gap: 12px;
padding: 0;
- margin-bottom: 20px;
+ margin-bottom: 12px;
}
.sortSelectWrapper {
@@ -121,7 +157,7 @@
gap: 10px;
flex: 1;
background: var(--color-bg-card);
- padding: 10px 16px;
+ padding: 8px 12px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: var(--color-focus-blue);
@@ -166,8 +202,8 @@
display: flex;
align-items: center;
justify-content: center;
- width: 44px;
- height: 44px;
+ width: 38px;
+ height: 38px;
border-radius: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
diff --git a/src/components/ConnectionTable.scss b/src/components/ConnectionTable.scss
index 41e3b97..1559fb5 100644
--- a/src/components/ConnectionTable.scss
+++ b/src/components/ConnectionTable.scss
@@ -1,5 +1,5 @@
.connections-table {
- td.ctrl {
+ .ctrl {
min-width: 3.5em;
text-align: center;
display: flex;
@@ -21,7 +21,7 @@
}
}
- td.type {
+ .type {
min-width: 8em;
text-align: center;
@@ -31,49 +31,49 @@
}
}
- td.start,
- td.downloadSpeedCurr,
- td.uploadSpeedCurr,
- td.download,
- td.upload {
+ .start,
+ .downloadSpeedCurr,
+ .uploadSpeedCurr,
+ .download,
+ .upload {
min-width: 7em;
text-align: center;
font-variant-numeric: tabular-nums;
}
- td.downloadSpeedCurr,
- td.download {
+ .downloadSpeedCurr,
+ .download {
color: #27ae60;
}
- td.uploadSpeedCurr,
- td.upload {
+ .uploadSpeedCurr,
+ .upload {
color: #3498db;
}
// 进程列
- td.process {
+ .process {
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
}
// 域名/Host列
- td.host {
+ .host {
max-width: 20em;
overflow: hidden;
text-overflow: ellipsis;
}
// 规则列
- td.rule {
+ .rule {
max-width: 15em;
overflow: hidden;
text-overflow: ellipsis;
}
// 节点链
- td.chains {
+ .chains {
// 不截断:完整展示节点链(必要时允许换行)
max-width: none;
overflow: visible;
diff --git a/src/components/ConnectionTable.tsx b/src/components/ConnectionTable.tsx
index 630167a..9dcb7ae 100644
--- a/src/components/ConnectionTable.tsx
+++ b/src/components/ConnectionTable.tsx
@@ -3,11 +3,11 @@ import './ConnectionTable.scss';
import cx from 'clsx';
import { formatDistance, Locale } from 'date-fns';
import { enUS, zhCN, zhTW } from 'date-fns/locale';
-import React, { useEffect, useMemo, useState } from 'react';
-import { ArrowDown, ArrowUp, ChevronDown, Sliders } from 'react-feather';
-import { XCircle } from 'react-feather';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from 'react-feather';
import { useTranslation } from 'react-i18next';
import { useSortBy, useTable } from 'react-table';
+import { FixedSizeList as List } from 'react-window';
import { FormattedConn } from '~/store/connections';
import { State } from '~/store/types';
@@ -23,19 +23,97 @@ import { connect } from './StateProvider';
const sortById = { id: 'id', desc: true };
-function Table({ data, columns, hiddenColumns, apiConfig }) {
+const COLUMN_WIDTHS = {
+ ctrl: 50,
+ start: 100,
+ type: 120,
+ host: 300,
+ rule: 180,
+ chains: 250,
+ download: 100,
+ upload: 100,
+ downloadSpeedCurr: 100,
+ uploadSpeedCurr: 100,
+ source: 170,
+ destinationIP: 170,
+ process: 130,
+ sniffHost: 150,
+};
+
+const TOTAL_WIDTH = Object.values(COLUMN_WIDTHS).reduce((a, b) => a + b, 0);
+
+const InnerElement = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
+ ({ style, ...rest }, ref) => (
+ <div
+ ref={ref}
+ style={{
+ ...style,
+ width: TOTAL_WIDTH,
+ }}
+ {...rest}
+ />
+ )
+);
+
+const getColumnStyle = (columnId: string) => {
+ const width = COLUMN_WIDTHS[columnId] || 100;
+ const style: React.CSSProperties = {
+ width,
+ minWidth: width,
+ flex: `0 0 ${width}px`,
+ flexShrink: 0,
+ };
+
+ if (['download', 'upload', 'downloadSpeedCurr', 'uploadSpeedCurr', 'start'].includes(columnId)) {
+ style.justifyContent = 'flex-end';
+ }
+
+ if (columnId === 'ctrl') {
+ style.justifyContent = 'center';
+ }
+
+ return style;
+};
+
+function Table({ data, columns, hiddenColumns, apiConfig, height }) {
const { t, i18n } = useTranslation();
const [operationId, setOperationId] = useState('');
const [showModalDisconnect, setShowModalDisconnect] = useState(false);
const [selectedConn, setSelectedConn] = useState<FormattedConn | null>(null);
- // 从本地存储加载排序状态
- const savedSortBy = JSON.parse(localStorage.getItem('tableSortBy')) || [sortById];
+ const [isMobile, setIsMobile] = useState(false);
- const tableState = {
- sortBy: savedSortBy,
- hiddenColumns,
- };
+ const headerRef = React.useRef<HTMLDivElement>(null);
+ const outerRef = React.useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const outer = outerRef.current;
+ if (!outer) return;
+ const handleScroll = () => {
+ if (headerRef.current) {
+ headerRef.current.scrollLeft = outer.scrollLeft;
+ }
+ };
+ outer.addEventListener('scroll', handleScroll);
+ return () => outer.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ useEffect(() => {
+ const mql = window.matchMedia('(max-width: 768px)');
+ setIsMobile(mql.matches);
+ const listener = (e) => setIsMobile(e.matches);
+ mql.addEventListener('change', listener);
+ return () => mql.removeEventListener('change', listener);
+ }, []);
+
+ // 从本地存储加载排序状态
+ const tableState = useMemo(() => {
+ const savedSortBy = JSON.parse(localStorage.getItem('tableSortBy')) || [sortById];
+ return {
+ sortBy: savedSortBy,
+ hiddenColumns,
+ };
+ }, [hiddenColumns]);
const table = useTable(
{
@@ -47,7 +125,7 @@ function Table({ data, columns, hiddenColumns, apiConfig }) {
useSortBy
);
- const { getTableProps, setHiddenColumns, headerGroups, rows, prepareRow, toggleSortBy } = table;
+ const { setHiddenColumns, headerGroups, rows, prepareRow, toggleSortBy } = table;
const state = table.state;
const sortOptions = useMemo(() => {
@@ -75,130 +153,214 @@ function Table({ data, columns, hiddenColumns, apiConfig }) {
locale = enUS;
}
- const disconnectOperation = () => {
+ const disconnectOperation = useCallback(() => {
connAPI.closeConnById(apiConfig, operationId);
setShowModalDisconnect(false);
- };
+ }, [apiConfig, operationId]);
- const handlerDisconnect = (id, e) => {
+ const handlerDisconnect = useCallback((id, e) => {
e.stopPropagation();
setOperationId(id);
setShowModalDisconnect(true);
- };
+ }, []);
- const renderCell = (cell, locale) => {
- switch (cell.column.id) {
- case 'ctrl':
- return (
- <XCircle
- style={{ cursor: 'pointer' }}
- onClick={(e) => handlerDisconnect(cell.row.original.id, e)}
- ></XCircle>
- );
- case 'start':
- return formatDistance(cell.value, 0, { locale: locale });
- case 'download':
- case 'upload':
- return prettyBytes(cell.value);
- case 'downloadSpeedCurr':
- case 'uploadSpeedCurr':
- return prettyBytes(cell.value) + '/s';
- default:
- return cell.value;
- }
- };
+ const renderCell = useCallback(
+ (cell, locale) => {
+ switch (cell.column.id) {
+ case 'ctrl':
+ return (
+ <XCircle
+ style={{ cursor: 'pointer' }}
+ onClick={(e) => handlerDisconnect(cell.row.original.id, e)}
+ ></XCircle>
+ );
+ case 'start':
+ return formatDistance(cell.value, 0, { locale: locale });
+ case 'download':
+ case 'upload':
+ return prettyBytes(cell.value);
+ case 'downloadSpeedCurr':
+ case 'uploadSpeedCurr':
+ return prettyBytes(cell.value) + '/s';
+ default:
+ return cell.value;
+ }
+ },
+ [handlerDisconnect]
+ );
// 当排序状态改变时,将新状态保存到本地存储
useEffect(() => {
localStorage.setItem('tableSortBy', JSON.stringify(state.sortBy));
}, [state.sortBy]);
+ const MobileRow = useCallback(
+ ({ index, style }) => {
+ const row = rows[index];
+ const conn = row.original as FormattedConn;
+ return (
+ <div style={style}>
+ <ConnectionCard
+ key={conn.id}
+ conn={conn}
+ onDisconnect={handlerDisconnect}
+ onClick={() => setSelectedConn(conn)}
+ />
+ </div>
+ );
+ },
+ [rows, handlerDisconnect]
+ );
+
+ const DesktopRow = useCallback(
+ ({ index, style }) => {
+ const row = rows[index];
+ prepareRow(row);
+ return (
+ <div
+ {...(row as any).getRowProps({
+ style: {
+ ...style,
+ display: 'flex',
+ width: TOTAL_WIDTH,
+ },
+ })}
+ className={s.tr}
+ onClick={() => setSelectedConn((row as any).original)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setSelectedConn((row as any).original);
+ }
+ }}
+ >
+ {row.cells.map((cell) => {
+ const columnStyle = getColumnStyle(cell.column.id);
+ return (
+ <div
+ {...cell.getCellProps()}
+ className={cx(s.td, index % 2 === 0 ? s.odd : false, cell.column.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ ...columnStyle,
+ }}
+ >
+ <span className={s.cellText}>{renderCell(cell, locale)}</span>
+ </div>
+ );
+ })}
+ </div>
+ );
+ },
+ [prepareRow, rows, renderCell, locale]
+ );
+
return (
- <div className={s.tableWrapper}>
- <div className={s.cardsView}>
- <div className={s.mobileSortToolbar}>
- <div className={s.sortSelectWrapper}>
- <div className={s.selectedValue}>
- <Sliders size={14} />
- <span>
- {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label}
- </span>
+ <div className={s.tableWrapper} style={{ height, overflow: 'hidden' }}>
+ {isMobile ? (
+ <div className={s.cardsView}>
+ <div className={s.mobileSortToolbar}>
+ <div className={s.sortSelectWrapper}>
+ <div className={s.selectedValue}>
+ <Sliders size={14} />
+ <span>
+ {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label}
+ </span>
+ </div>
+ <select
+ value={currentSort.id}
+ onChange={(e) => toggleSortBy(e.target.value, currentSort.desc)}
+ >
+ {sortOptions.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <ChevronDown size={14} className={s.selectArrow} />
</div>
- <select
- value={currentSort.id}
- onChange={(e) => toggleSortBy(e.target.value, currentSort.desc)}
+ <button
+ className={s.sortDirBtn}
+ onClick={() => toggleSortBy(currentSort.id, !currentSort.desc)}
>
- {sortOptions.map((opt) => (
- <option key={opt.value} value={opt.value}>
- {opt.label}
- </option>
+ {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />}
+ </button>
+ </div>
+ <List height={height - 50} itemCount={rows.length} itemSize={120} width="100%">
+ {MobileRow}
+ </List>
+ </div>
+ ) : (
+ <div
+ className={cx(s.table, 'connections-table')}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ }}
+ >
+ <div
+ className={s.theadWrapper}
+ ref={headerRef}
+ style={{ overflow: 'hidden', width: '100%' }}
+ >
+ <div className={s.thead} style={{ width: TOTAL_WIDTH }}>
+ {headerGroups.map((headerGroup, trindex) => (
+ <div
+ {...headerGroup.getHeaderGroupProps()}
+ className={s.tr}
+ key={trindex}
+ style={{ display: 'flex' }}
+ >
+ {headerGroup.headers.map((column) => {
+ const columnStyle = getColumnStyle(column.id);
+ return (
+ <div
+ {...column.getHeaderProps(column.getSortByToggleProps())}
+ className={s.th}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ ...columnStyle,
+ }}
+ >
+ <span className={s.headerText}>{t(column.render('Header'))}</span>
+ {column.id !== 'ctrl' ? (
+ <span className={s.sortIconContainer}>
+ {column.isSorted ? (
+ <ChevronDown
+ size={14}
+ className={column.isSortedDesc ? '' : s.rotate180}
+ />
+ ) : null}
+ </span>
+ ) : null}
+ </div>
+ );
+ })}
+ </div>
))}
- </select>
- <ChevronDown size={14} className={s.selectArrow} />
+ </div>
</div>
- <button
- className={s.sortDirBtn}
- onClick={() => toggleSortBy(currentSort.id, !currentSort.desc)}
+ <List
+ height={height - 50}
+ itemCount={rows.length}
+ itemSize={44}
+ width="100%"
+ outerRef={outerRef}
+ innerElementType={InnerElement}
>
- {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />}
- </button>
+ {DesktopRow}
+ </List>
</div>
- {rows.map((row) => {
- const conn = row.original as FormattedConn;
- return (
- <ConnectionCard
- key={conn.id}
- conn={conn}
- onDisconnect={handlerDisconnect}
- onClick={() => setSelectedConn(conn)}
- />
- );
- })}
- </div>
- <table {...getTableProps()} className={cx(s.table, 'connections-table')}>
- <thead>
- {headerGroups.map((headerGroup, trindex) => {
- return (
- <tr {...headerGroup.getHeaderGroupProps()} className={s.tr} key={trindex}>
- {headerGroup.headers.map((column) => (
- <th {...column.getHeaderProps(column.getSortByToggleProps())} className={s.th}>
- <span>{t(column.render('Header'))}</span>
- {column.id !== 'ctrl' ? (
- <span className={s.sortIconContainer}>
- {column.isSorted ? (
- <ChevronDown
- size={16}
- className={column.isSortedDesc ? '' : s.rotate180}
- />
- ) : null}
- </span>
- ) : null}
- </th>
- ))}
- </tr>
- );
- })}
- </thead>
- <tbody>
- {rows.map((row, i) => {
- prepareRow(row);
- return (
- <tr className={s.tr} key={i} onClick={() => setSelectedConn((row as any).original)}>
- {row.cells.map((cell) => {
- return (
- <td
- {...cell.getCellProps()}
- className={cx(s.td, i % 2 === 0 ? s.odd : false, cell.column.id)}
- >
- {renderCell(cell, locale)}
- </td>
- );
- })}
- </tr>
- );
- })}
- </tbody>
- </table>
+ )}
<MOdalCloseConnection
confirm={'disconnect'}
isOpen={showModalDisconnect}
diff --git a/src/components/Connections.module.scss b/src/components/Connections.module.scss
index d81922a..e355f2b 100644
--- a/src/components/Connections.module.scss
+++ b/src/components/Connections.module.scss
@@ -27,27 +27,16 @@
min-width: 20px;
}
-.header {
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- padding: 20px 30px 10px;
- gap: 20px;
-
- @media (max-width: 768px) {
- grid-template-columns: 1fr;
- padding: 15px 15px 5px;
- gap: 12px;
- }
-}
-
.inputWrapper {
width: 100%;
- max-width: 320px;
- justify-self: end;
+ max-width: 200px;
+ margin-left: auto;
@media (max-width: 768px) {
max-width: none;
+ grid-column: 1 / -1;
+ margin-left: 0;
+ order: 3;
}
}
@@ -90,9 +79,12 @@
border-radius: 12px;
@media (max-width: 768px) {
- justify-content: center;
- width: 100%;
- padding: 8px;
+ grid-column: 2;
+ grid-row: 2;
+ justify-content: flex-end;
+ width: auto;
+ padding: 2px;
+ background: transparent;
}
}
@@ -143,14 +135,14 @@
display: flex;
justify-content: space-between;
align-items: center;
- padding: 0 30px;
gap: 16px;
+ width: 100%;
@media (max-width: 768px) {
- flex-direction: column;
- align-items: stretch;
- padding: 0 15px;
- gap: 12px;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ padding: 4px 0;
}
}
@@ -160,9 +152,7 @@
gap: 16px;
@media (max-width: 768px) {
- flex-direction: column;
- align-items: stretch;
- gap: 12px;
+ display: contents;
}
}
@@ -174,12 +164,16 @@
gap: 4px;
@media (max-width: 768px) {
+ grid-column: 1 / -1;
justify-content: space-between;
width: 100%;
:global(.react-tabs__tab) {
flex: 1;
+ text-align: center;
justify-content: center;
+ padding: 6px 4px !important;
+ font-size: 0.9em;
}
}
}
@@ -190,6 +184,8 @@
@media (max-width: 768px) {
width: 100% !important;
+ grid-column: 1;
+ height: 36px;
}
}
@@ -200,3 +196,23 @@
opacity: 0.2;
margin: 0 2px;
}
+
+.contentWrapper {
+ margin: 0 45px 20px;
+ background-color: var(--bg-log-info-card);
+ border-radius: 12px;
+ box-shadow: var(--shadow-card);
+ border: 1px solid var(--color-separator);
+ overflow: hidden;
+
+ @media (max-width: 768px) {
+ margin: 10px 15px 15px;
+ background-color: transparent;
+ border: none;
+ box-shadow: none;
+ }
+}
+
+.scrollArea {
+ overflow: visible;
+}
diff --git a/src/components/Connections.tsx b/src/components/Connections.tsx
index bff97e9..1e83702 100644
--- a/src/components/Connections.tsx
+++ b/src/components/Connections.tsx
@@ -30,7 +30,7 @@ import { Fab, position as fabPosition } from './shared/Fab';
import { connect } from './StateProvider';
import SvgYacd from './SvgYacd';
-const { useEffect, useState, useRef, useCallback } = React;
+const { useEffect, useState, useRef, useCallback, useMemo } = React;
const ALL_SOURCE_IP = 'ALL_SOURCE_IP';
const sourceMapInit = localStorage.getItem('sourceMap')
@@ -110,11 +110,24 @@ function getNameFromSource(
function formatConnectionDataItem(
i: ConnectionItem,
- prevKv: Record<string, { upload: number; download: number }>,
+ prevKv: Record<string, FormattedConn>,
now: number,
sourceMap: { reg: string; name: string }[]
): FormattedConn {
- const { id, metadata, upload, download, start, chains, rule, rulePayload } = i;
+ const { id, upload, download, start, chains, rule, rulePayload, metadata } = i;
+ const prev = prevKv[id];
+
+ if (prev) {
+ return {
+ ...prev,
+ upload,
+ download,
+ start: now - prev.startTime,
+ downloadSpeedCurr: download - prev.download,
+ uploadSpeedCurr: upload - prev.upload,
+ };
+ }
+
const {
host,
destinationPort,
@@ -128,29 +141,28 @@ function formatConnectionDataItem(
sniffHost,
} = 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 host2 = host || destinationIP;
const source = `${sourceIP}:${sourcePort}`;
+ const startTime = new Date(start).valueOf();
- const ret = {
+ return {
id,
upload,
download,
- start: now - new Date(start).valueOf(),
+ start: now - startTime,
+ startTime,
chains: modifyChains(chains),
rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`,
...metadata,
host: `${host2}:${destinationPort}`,
- sniffHost: sniffHost ? sniffHost : '-',
+ sniffHost: sniffHost || '-',
type: `${type}(${network})`,
source: getNameFromSource(sourceIP, sourceMap, source),
- downloadSpeedCurr: download - (prev ? prev.download : 0),
- uploadSpeedCurr: upload - (prev ? prev.upload : 0),
- process: process ? process : '-',
+ downloadSpeedCurr: 0,
+ uploadSpeedCurr: 0,
+ process: process || '-',
destinationIP: remoteDestination || destinationIP || host,
};
- return ret;
}
function modifyChains(chains: string[]): string {
if (!Array.isArray(chains) || chains.length === 0) {
@@ -161,19 +173,12 @@ function modifyChains(chains: string[]): string {
return chains[0];
}
- //倒序
- if (chains.length === 2) {
- return `${chains[1]} -> ${chains[0]}`;
- }
-
- const first = chains.pop();
- const last = chains.shift();
- return `${first} -> ${last}`;
+ return `${chains[chains.length - 1]} -> ${chains[0]}`;
}
-function renderTableOrPlaceholder(columns, hiddenColumns, conns: FormattedConn[]) {
+function renderTableOrPlaceholder(columns, hiddenColumns, conns: FormattedConn[], height: number) {
return conns.length > 0 ? (
- <ConnectionTable data={conns} columns={columns} hiddenColumns={hiddenColumns} />
+ <ConnectionTable data={conns} columns={columns} hiddenColumns={hiddenColumns} height={height} />
) : (
<div className={s.placeHolder}>
<SvgYacd width={200} height={200} c1="var(--color-text)" />
@@ -254,10 +259,16 @@ function Conn({ apiConfig }) {
const [filterKeyword, setFilterKeyword] = useState('');
const [filterSourceIpStr, setFilterSourceIpStr] = useState(ALL_SOURCE_IP);
- const filteredConns = filterConns(conns, filterKeyword, filterSourceIpStr);
- const filteredClosedConns = filterConns(closedConns, filterKeyword, filterSourceIpStr);
+ const filteredConns = useMemo(
+ () => filterConns(conns, filterKeyword, filterSourceIpStr),
+ [conns, filterKeyword, filterSourceIpStr]
+ );
+ const filteredClosedConns = useMemo(
+ () => filterConns(closedConns, filterKeyword, filterSourceIpStr),
+ [closedConns, filterKeyword, filterSourceIpStr]
+ );
- const getConnIpList = (conns: FormattedConn[]) => {
+ const connIpSet = useMemo(() => {
return [
[ALL_SOURCE_IP, t('All')],
...Array.from(new Set(conns.map((x) => x.sourceIP)))
@@ -266,8 +277,7 @@ function Conn({ apiConfig }) {
return [value, getNameFromSource(value, sourceMap).trim() || t('internel')];
}),
];
- };
- const connIpSet = getConnIpList(conns);
+ }, [conns, t, sourceMap]);
// const ClosedConnIpSet = getConnIpList(closedConns);
const [isCloseFilterModalOpen, setIsCloseFilterModalOpen] = useState(false);
@@ -308,10 +318,12 @@ function Conn({ apiConfig }) {
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 (closed.length > 0) {
+ 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) {
@@ -350,86 +362,91 @@ function Conn({ apiConfig }) {
return (
<div>
- <div className={s.header}>
- <ContentHeader title={t('Connections')} />
- <div className={s.inputWrapper}>
- <Input
- type="text"
- name="filter"
- autoComplete="off"
- className={s.input}
- placeholder={t('Search')}
- onChange={(e) => setFilterKeyword(e.target.value)}
- />
- </div>
- </div>
<Tabs>
- <div className={s.controls}>
- <div className={s.tabGroup}>
- <TabList className={s.tabList}>
- <Tab>
- <span>{t('Active')}</span>
- <span className={s.connQty}>
- {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
- <ConnQty qty={filteredConns.length} />
- </span>
- </Tab>
- <Tab>
- <span>{t('Closed')}</span>
- <span className={s.connQty}>
- {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
- <ConnQty qty={filteredClosedConns.length} />
- </span>
- </Tab>
- </TabList>
- <Select
- options={connIpSet}
- selected={filterSourceIpStr}
- className={s.sourceSelect}
- onChange={(e) => setFilterSourceIpStr(e.target.value)}
- />
- </div>
- <div className={s.toolbar}>
- <button
- className={s.toolbarBtn}
- onClick={openCloseAllModal}
- title={t('close_all_connections')}
- >
- <IconClose size={15} />
- </button>
- <button
- className={s.toolbarBtn}
- onClick={openCloseFilterModal}
- title={t('close_filter_connections')}
- >
- <IconClose size={13} />
- <span className={s.toolbarBtnBadge}>F</span>
- </button>
- <span className={s.toolbarDivider} />
- <button
- className={s.toolbarBtn}
- onClick={() => setModalColumn(true)}
- title={t('manage_column')}
- >
- <Settings size={15} />
- </button>
- <button className={s.toolbarBtn} onClick={resetColumns} title={t('reset_column')}>
- <RefreshCcw size={15} />
- </button>
- <button className={s.toolbarBtn} onClick={openModalSource} title={t('client_tag')}>
- <Tag size={15} />
- </button>
+ <ContentHeader>
+ <div className={s.controls}>
+ <div className={s.tabGroup}>
+ <TabList className={s.tabList}>
+ <Tab>
+ <span>{t('Active')}</span>
+ <span className={s.connQty}>
+ {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
+ <ConnQty qty={filteredConns.length} />
+ </span>
+ </Tab>
+ <Tab>
+ <span>{t('Closed')}</span>
+ <span className={s.connQty}>
+ {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
+ <ConnQty qty={filteredClosedConns.length} />
+ </span>
+ </Tab>
+ </TabList>
+ <Select
+ options={connIpSet}
+ selected={filterSourceIpStr}
+ className={s.sourceSelect}
+ onChange={(e) => setFilterSourceIpStr(e.target.value)}
+ />
+ </div>
+ <div style={{ flex: 1 }} />
+ <div className={s.inputWrapper}>
+ <Input
+ type="text"
+ name="filter"
+ autoComplete="off"
+ className={s.input}
+ placeholder={t('Search')}
+ onChange={(e) => setFilterKeyword(e.target.value)}
+ />
+ </div>
+ <div className={s.toolbar}>
+ <button
+ className={s.toolbarBtn}
+ onClick={openCloseAllModal}
+ title={t('close_all_connections')}
+ >
+ <IconClose size={15} />
+ </button>
+ <button
+ className={s.toolbarBtn}
+ onClick={openCloseFilterModal}
+ title={t('close_filter_connections')}
+ >
+ <IconClose size={13} />
+ <span className={s.toolbarBtnBadge}>F</span>
+ </button>
+ <span className={s.toolbarDivider} />
+ <button
+ className={s.toolbarBtn}
+ onClick={() => setModalColumn(true)}
+ title={t('manage_column')}
+ >
+ <Settings size={15} />
+ </button>
+ <button className={s.toolbarBtn} onClick={resetColumns} title={t('reset_column')}>
+ <RefreshCcw size={15} />
+ </button>
+ <button className={s.toolbarBtn} onClick={openModalSource} title={t('client_tag')}>
+ <Tag size={15} />
+ </button>
+ </div>
</div>
- </div>
- <div ref={refContainer} style={{ padding: 30, paddingBottom: 10, paddingTop: 10 }}>
+ </ContentHeader>
+ <div ref={refContainer} className={s.contentWrapper}>
<div
+ className={s.scrollArea}
style={{
height: containerHeight - paddingBottom,
- overflow: 'auto',
}}
>
<TabPanel>
- {renderTableOrPlaceholder(columns, hiddenColumns, filteredConns)}
+ {renderTableOrPlaceholder(
+ columns,
+ hiddenColumns,
+ filteredConns,
+ containerHeight - paddingBottom
+ )}
<Fab
icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />}
mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}}
@@ -439,7 +456,12 @@ function Conn({ apiConfig }) {
/>
</TabPanel>
<TabPanel>
- {renderTableOrPlaceholder(columns, hiddenColumns, filteredClosedConns)}
+ {renderTableOrPlaceholder(
+ columns,
+ hiddenColumns,
+ filteredClosedConns,
+ containerHeight - paddingBottom
+ )}
</TabPanel>
</div>
</div>
diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss
index 3af15c6..b71b37e 100644
--- a/src/components/ContentHeader.module.scss
+++ b/src/components/ContentHeader.module.scss
@@ -1,17 +1,32 @@
@import '~/styles/utils/custom-media';
.root {
- height: 76px;
+ height: 60px;
display: flex;
align-items: center;
+ padding: 0 15px;
+
+ @media (max-width: 768px) {
+ min-height: 48px;
+ height: auto;
+ padding: 5px 10px;
+ flex-wrap: wrap;
+ }
+
+ @media (--breakpoint-not-small) {
+ padding: 0 40px;
+ }
}
.h1 {
white-space: nowrap;
- padding: 0 15px;
font-size: 1.7em;
+
+ @media (max-width: 768px) {
+ font-size: 1.4em;
+ }
+
@media (--breakpoint-not-small) {
- padding: 0 40px;
font-size: 2em;
}
text-align: left;
diff --git a/src/components/ContentHeader.tsx b/src/components/ContentHeader.tsx
index 473cd4c..4709037 100644
--- a/src/components/ContentHeader.tsx
+++ b/src/components/ContentHeader.tsx
@@ -3,15 +3,11 @@ import React from 'react';
import s0 from './ContentHeader.module.scss';
type Props = {
- title: string;
+ children?: React.ReactNode;
};
-function ContentHeader({ title }: Props) {
- return (
- <div className={s0.root}>
- <h1 className={s0.h1}>{title}</h1>
- </div>
- );
+function ContentHeader({ children }: Props) {
+ return <div className={s0.root}>{children}</div>;
}
export default React.memo(ContentHeader);
diff --git a/src/components/Home.tsx b/src/components/Home.tsx
index c783db4..8a98987 100644
--- a/src/components/Home.tsx
+++ b/src/components/Home.tsx
@@ -1,15 +1,13 @@
import React from 'react';
-import { useTranslation } from 'react-i18next';
import ContentHeader from './ContentHeader';
import s0 from './Home.module.scss';
import TrafficNow from './TrafficNow';
export default function Home() {
- const { t } = useTranslation();
return (
<div>
- <ContentHeader title={t('Overview')} />
+ <ContentHeader />
<div className={s0.root}>
<TrafficNow />
</div>
diff --git a/src/components/Logs.module.scss b/src/components/Logs.module.scss
index 2eb0022..ce3bc1e 100644
--- a/src/components/Logs.module.scss
+++ b/src/components/Logs.module.scss
@@ -1,75 +1,170 @@
-.logMeta {
- font-size: 0.8em;
- margin-bottom: 5px;
- display: block;
- line-height: 1.55em;
+.headerControls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ justify-content: flex-end;
+ max-width: 400px;
+
+ & > div {
+ flex: 1;
+ }
}
-.logType {
- flex-shrink: 0;
- text-align: center;
- width: 66px;
- border-radius: 100px;
- padding: 3px 5px;
- margin: 0 8px;
+.searchWrapper {
+ flex: 1;
}
-.logTime {
- flex-shrink: 0;
- color: #fb923c;
+.clearBtn {
+ background: none;
+ border: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s, color 0.2s;
+
+ &:hover {
+ background-color: var(--bg-near-transparent);
+ color: var(--color-text);
+ }
}
-.logText {
- flex-shrink: 0;
- color: #888;
+.logLine {
+ display: flex;
+ font-family: var(--font-normal);
+ font-size: 12px;
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--bg-near-transparent);
+ word-break: break-all;
+
+ &:hover {
+ background-color: var(--bg-near-transparent);
+ }
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ }
+}
+
+.logMeta {
+ display: flex;
align-items: center;
- line-height: 1.35em;
- /* force wrap */
- width: 100%;
+ flex-shrink: 0;
+ margin-right: 12px;
+ min-width: 180px;
+
@media (max-width: 768px) {
- display: inline-block;
+ margin-bottom: 4px;
}
}
-/*******************/
+.logTime {
+ color: var(--color-text-secondary);
+ margin-right: 8px;
+ opacity: 0.8;
+}
+
+.logType {
+ text-transform: uppercase;
+ font-weight: bold;
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ min-width: 50px;
+ text-align: center;
+
+ &[data-type='debug'] {
+ color: #389d3d;
+ background-color: rgba(56, 157, 61, 0.1);
+ }
+ &[data-type='info'] {
+ color: #0ea5e9;
+ background-color: rgba(14, 165, 233, 0.1);
+ }
+ &[data-type='warning'] {
+ color: #f59e0b;
+ background-color: rgba(245, 158, 11, 0.1);
+ }
+ &[data-type='error'] {
+ color: #ef4444;
+ background-color: rgba(239, 68, 68, 0.1);
+ }
+}
+
+.logText {
+ color: var(--color-text);
+ line-height: 1.5;
+ flex: 1;
+}
.logsWrapper {
- margin: 45px;
+ position: relative;
+ margin: 20px 45px;
padding: 10px;
background-color: var(--bg-log-info-card);
- border-radius: 4px;
+ border-radius: 8px;
color: var(--color-text);
overflow-y: auto;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+
@media (max-width: 768px) {
- margin: 25px;
+ margin: 15px 25px;
}
- :global {
- .log {
- margin-bottom: 10px;
- //background: var(--color-background);
- }
- .log.even {
- //background: var(--color-background);
- }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--bg-near-transparent);
+ border-radius: 3px;
}
}
-/*******************/
+.scrollToBottomBtn {
+ position: absolute;
+ bottom: 80px;
+ right: 20px;
+ background-color: var(--color-focus-blue);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ z-index: 10;
+ transition: transform 0.2s;
+
+ &:hover {
+ transform: scale(1.1);
+ }
+
+ @media (max-width: 768px) {
+ right: 25px;
+ }
+}
.logPlaceholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- color: #2d2d30;
+ color: var(--color-text-secondary);
div:nth-child(2) {
- color: var(--color-text-secondary);
- font-size: 1.4em;
+ font-size: 1.2em;
+ margin-top: 20px;
opacity: 0.6;
}
}
.logPlaceholderIcon {
- opacity: 0.3;
+ opacity: 0.2;
}
diff --git a/src/components/Logs.tsx b/src/components/Logs.tsx
index 22f2878..ae7e2a9 100644
--- a/src/components/Logs.tsx
+++ b/src/components/Logs.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { Pause, Play } from 'react-feather';
+import { ArrowDown, Pause, Play, Trash2 } from 'react-feather';
import { useTranslation } from 'react-i18next';
import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from '~/api/logs';
@@ -10,20 +10,13 @@ import SvgYacd from '~/components/SvgYacd';
import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight';
import { getClashAPIConfig, getLogStreamingPaused } from '~/store/app';
import { getLogLevel } from '~/store/configs';
-import { appendLog, getLogsForDisplay } from '~/store/logs';
+import { appendLog, clearLogs, getLogsForDisplay } from '~/store/logs';
import { Log, State } from '~/store/types';
import s from './Logs.module.scss';
import { Fab, position as fabPosition } from './shared/Fab';
-const { useCallback, useEffect } = React;
-
-const colors = {
- debug: '#389d3d',
- info: '#58c3f2',
- warning: '#cc5abb',
- error: '#c11c1c',
-};
+const { useCallback, useEffect, useRef, useState } = React;
const logTypes = {
debug: 'debug',
@@ -36,12 +29,14 @@ type LogLineProps = Partial<Log>;
function LogLine({ time, payload, type }: LogLineProps) {
return (
- <div className={s.logMeta}>
- <span className={s.logTime}>{time}</span>
- <span className={s.logType} style={{ color: colors[type] }}>
- [ {logTypes[type]} ]
- </span>
- <span className={s.logText}>{payload}</span>
+ <div className={s.logLine}>
+ <div className={s.logMeta}>
+ <span className={s.logTime}>{time}</span>
+ <span className={s.logType} data-type={type}>
+ {logTypes[type]}
+ </span>
+ </div>
+ <div className={s.logText}>{payload}</div>
</div>
);
}
@@ -61,11 +56,39 @@ function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) {
const [refLogsContainer, containerHeight] = useRemainingViewPortHeight();
const { t } = useTranslation();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [isAtBottom, setIsAtBottom] = useState(true);
+
+ const scrollToBottom = useCallback(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isAtBottom) {
+ scrollToBottom();
+ }
+ }, [logs, isAtBottom, scrollToBottom]);
+
+ const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
+ const atBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setIsAtBottom(atBottom);
+ }, []);
+
return (
<div>
- <ContentHeader title={t('Logs')} />
- <LogSearch />
- <div ref={refLogsContainer}>
+ <ContentHeader>
+ <div style={{ flex: 1 }} />
+ <div className={s.headerControls}>
+ <LogSearch className={s.searchWrapper} />
+ <button className={s.clearBtn} onClick={() => dispatch(clearLogs())} title={t('Clear')}>
+ <Trash2 size={18} />
+ </button>
+ </div>
+ </ContentHeader>
+ <div ref={refLogsContainer} style={{ position: 'relative' }}>
{logs.length === 0 ? (
<div className={s.logPlaceholder} style={{ height: containerHeight * 0.9 }}>
<div className={s.logPlaceholderIcon}>
@@ -74,12 +97,23 @@ function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) {
<div>{t('no_logs')}</div>
</div>
) : (
- <div className={s.logsWrapper} style={{ height: containerHeight * 0.85 }}>
- {logs.map((log, index) => (
- <div className="" key={index}>
- <LogLine {...log} />
- </div>
- ))}
+ <>
+ <div
+ className={s.logsWrapper}
+ style={{ height: containerHeight * 0.8 }}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ {logs.map((log, index) => (
+ <LogLine {...log} key={log.id || index} />
+ ))}
+ </div>
+
+ {!isAtBottom && (
+ <button className={s.scrollToBottomBtn} onClick={scrollToBottom}>
+ <ArrowDown size={16} />
+ </button>
+ )}
<Fab
icon={logStreamingPaused ? <Play size={16} /> : <Pause size={16} />}
@@ -88,7 +122,7 @@ function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) {
text={logStreamingPaused ? t('Resume Refresh') : t('Pause Refresh')}
onClick={toggleIsRefreshPaused}
></Fab>
- </div>
+ </>
)}
</div>
</div>
diff --git a/src/components/Rule.module.scss b/src/components/Rule.module.scss
index efee1f4..845fa5e 100644
--- a/src/components/Rule.module.scss
+++ b/src/components/Rule.module.scss
@@ -3,16 +3,12 @@
.rule {
display: flex;
align-items: center;
- padding: 8px 15px;
+ padding: 12px 20px;
transition: background-color 0.2s ease;
- border-bottom: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05));
+ border-bottom: 1px solid var(--color-separator);
&:hover {
- background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.02));
- }
-
- @media (--breakpoint-not-small) {
- padding: 12px 40px;
+ background-color: var(--bg-near-transparent);
}
}
@@ -20,9 +16,9 @@
width: 40px;
flex-shrink: 0;
color: var(--color-text-secondary);
- font-size: 0.8rem;
- opacity: 0.5;
- font-family: 'Roboto Mono', monospace;
+ font-size: 11px;
+ opacity: 0.4;
+ font-family: var(--font-mono);
}
.right {
@@ -38,8 +34,8 @@
}
.payload {
- font-family: 'Roboto Mono', Menlo, monospace;
- font-size: 0.95rem;
+ font-family: var(--font-mono);
+ font-size: 13px;
color: var(--color-text);
word-break: break-all;
line-height: 1.4;
@@ -47,19 +43,20 @@
.size {
margin-left: 12px;
- font-size: 0.75rem;
+ font-size: 10px;
color: var(--color-text-secondary);
- background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
+ background: var(--bg-near-transparent);
padding: 1px 6px;
- border-radius: 4px;
+ border-radius: 3px;
white-space: nowrap;
+ text-transform: uppercase;
}
.metaRow {
display: flex;
align-items: center;
gap: 12px;
- font-size: 0.8rem;
+ font-size: 11px;
}
.typeTag {
@@ -67,12 +64,14 @@
align-items: center;
gap: 4px;
color: var(--color-text-secondary);
- background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
+ background: var(--bg-near-transparent);
padding: 2px 8px;
border-radius: 4px;
span {
font-weight: 500;
+ text-transform: uppercase;
+ font-size: 10px;
}
}
diff --git a/src/components/Rules.module.scss b/src/components/Rules.module.scss
index ee49394..48eb8a0 100644
--- a/src/components/Rules.module.scss
+++ b/src/components/Rules.module.scss
@@ -6,40 +6,85 @@
height: 100%;
}
-.header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px 20px;
- margin-bottom: 8px;
- flex-shrink: 0;
+.filterWrapper {
+ width: 100%;
+ max-width: 300px;
- @media (--breakpoint-not-small) {
- padding: 12px 40px;
+ @media (max-width: 768px) {
+ max-width: none;
+ margin-top: 8px;
}
}
-.title {
- margin: 0;
- font-size: 1.5rem;
- font-weight: 700;
- color: var(--color-text);
- white-space: nowrap;
+.listWrapper {
+ margin: 0 45px 20px;
+ border-radius: 12px;
+ box-shadow: var(--shadow-card);
+ border: 1px solid var(--color-separator);
+ overflow: hidden;
- @media (--breakpoint-not-small) {
- font-size: 1.75rem;
+ @media (max-width: 768px) {
+ margin: 0 15px 15px;
}
}
-.filterWrapper {
- width: 100%;
- max-width: 300px;
- margin-left: 20px;
+.RuleProviderItemWrapper {
+ border-bottom: 1px solid var(--color-separator);
}
-.RuleProviderItemWrapper {
- padding: 8px 15px;
- @media (--breakpoint-not-small) {
- padding: 10px 40px;
+.tabsContainer {
+ display: flex;
+ align-items: center;
+ background-color: var(--color-bg-sidebar);
+ border-radius: 12px;
+ padding: 4px;
+
+ @media (max-width: 768px) {
+ flex: 1;
}
}
+
+.tab {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1em;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ transition: all 0.2s ease;
+ user-select: none;
+
+ @media (max-width: 768px) {
+ padding: 6px 10px;
+ font-size: 0.85em;
+ flex: 1;
+ justify-content: center;
+ }
+
+ &:hover {
+ color: var(--color-text-primary);
+ background: var(--bg-near-transparent);
+ }
+
+ &.active {
+ background-color: var(--color-focus-blue);
+ color: #fff;
+ box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
+ }
+}
+
+.tabCount {
+ font-family: var(--font-normal);
+ font-size: 0.7em;
+ margin-left: 6px;
+ padding: 2px 8px;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ background-color: rgba(255, 255, 255, 0.15);
+ border-radius: 10px;
+ font-weight: 600;
+ min-width: 20px;
+}
diff --git a/src/components/Rules.tsx b/src/components/Rules.tsx
index dabc4a1..0f5efa0 100644
--- a/src/components/Rules.tsx
+++ b/src/components/Rules.tsx
@@ -1,7 +1,9 @@
+import cx from 'clsx';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { areEqual, VariableSizeList } from 'react-window';
+import ContentHeader from '~/components/ContentHeader';
import { RuleProviderItem } from '~/components/rules/RuleProviderItem';
import { useRuleAndProvider } from '~/components/rules/rules.hooks';
import { RulesPageFab } from '~/components/rules/RulesPageFab';
@@ -16,9 +18,7 @@ import Rule from './Rule';
import s from './Rules.module.scss';
import { connect } from './StateProvider';
-const { memo } = React;
-
-const paddingBottom = 30;
+const { memo, useState, useCallback } = React;
type ItemData = {
rules: any[];
@@ -27,19 +27,15 @@ type ItemData = {
};
function itemKey(index: number, { rules, provider }: ItemData) {
- const providerQty = provider.names.length;
-
- if (index < providerQty) {
+ if (!rules) {
return provider.names[index];
}
- const item = rules[index - providerQty];
- return item.id;
+ return rules[index].id;
}
-function getItemSizeFactory({ provider }) {
- return function getItemSize(idx: number) {
- const providerQty = provider.names.length;
- if (idx < providerQty) {
+function getItemSizeFactory({ isRulesTab }) {
+ return function getItemSize() {
+ if (!isRulesTab) {
// provider
return 100;
}
@@ -51,9 +47,8 @@ function getItemSizeFactory({ provider }) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'index' does not exist on type '{ childre... Remove this comment to see the full error message
const Row = memo(({ index, style, data }) => {
const { rules, provider, apiConfig } = data;
- const providerQty = provider.names.length;
- if (index < providerQty) {
+ if (!rules) {
const name = provider.names[index];
const item = provider.byName[name];
return (
@@ -63,7 +58,7 @@ const Row = memo(({ index, style, data }) => {
);
}
- const r = rules[index - providerQty];
+ const r = rules[index];
return (
<div style={style}>
<Rule {...r} />
@@ -84,25 +79,63 @@ type RulesProps = {
function Rules({ apiConfig }: RulesProps) {
const [refRulesContainer, containerHeight] = useRemainingViewPortHeight();
const { rules, provider } = useRuleAndProvider(apiConfig);
- const getItemSize = getItemSizeFactory({ provider });
+ const [activeTab, setActiveTab] = useState('rules');
+
+ const formatQty = (qty: number) => (qty < 100 ? '' + qty : '99+');
+
+ const handleTabKeyDown = useCallback(
+ (tab: string) => (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ setActiveTab(tab);
+ }
+ },
+ []
+ );
+
+ const isRulesTab = activeTab === 'rules';
+ const getItemSize = getItemSizeFactory({ isRulesTab });
const { t } = useTranslation();
return (
<div className={s.container}>
- <div className={s.header}>
- <h1 className={s.title}>{t('Rules')}</h1>
+ <ContentHeader>
+ <div className={s.tabsContainer}>
+ <div
+ className={cx(s.tab, { [s.active]: activeTab === 'rules' })}
+ onClick={() => setActiveTab('rules')}
+ onKeyDown={handleTabKeyDown('rules')}
+ role="button"
+ tabIndex={0}
+ >
+ {t('Rules')}
+ <span className={s.tabCount}>{formatQty(rules.length)}</span>
+ </div>
+ {provider.names.length > 0 && (
+ <div
+ className={cx(s.tab, { [s.active]: activeTab === 'providers' })}
+ onClick={() => setActiveTab('providers')}
+ onKeyDown={handleTabKeyDown('providers')}
+ role="button"
+ tabIndex={0}
+ >
+ {t('rule_provider')}
+ <span className={s.tabCount}>{formatQty(provider.names.length)}</span>
+ </div>
+ )}
+ </div>
+ <div style={{ flex: 1 }} />
<div className={s.filterWrapper}>
<TextFilter textAtom={ruleFilterText} placeholder={t('Search')} />
</div>
- </div>
- <div ref={refRulesContainer} style={{ paddingBottom }}>
+ </ContentHeader>
+ <div ref={refRulesContainer} className={s.listWrapper}>
<VariableSizeList
- height={containerHeight - paddingBottom}
+ height={containerHeight}
width="100%"
- itemCount={rules.length + provider.names.length}
+ itemCount={isRulesTab ? rules.length : provider.names.length}
itemSize={getItemSize}
- itemData={{ rules, provider, apiConfig }}
+ itemData={{ rules: isRulesTab ? rules : null, provider, apiConfig }}
itemKey={itemKey}
>
{Row}
diff --git a/src/components/Search.tsx b/src/components/Search.tsx
index 0e0269c..4ee4963 100644
--- a/src/components/Search.tsx
+++ b/src/components/Search.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import s0 from './Search.module.scss';
-function RuleSearch({ dispatch, searchText, updateSearchText }) {
+function RuleSearch({ dispatch, searchText, updateSearchText, className }) {
const { t } = useTranslation();
const [text, setText] = useState(searchText);
const updateSearchTextInternal = useCallback(
@@ -24,7 +24,7 @@ function RuleSearch({ dispatch, searchText, updateSearchText }) {
};
return (
- <div className={s0.RuleSearch}>
+ <div className={className || s0.RuleSearch}>
<div className={s0.RuleSearchContainer}>
<div className={s0.inputWrapper}>
<input
diff --git a/src/components/proxies/Proxies.module.scss b/src/components/proxies/Proxies.module.scss
index d3295cd..2bcbc92 100644
--- a/src/components/proxies/Proxies.module.scss
+++ b/src/components/proxies/Proxies.module.scss
@@ -3,14 +3,13 @@
.topBar {
position: sticky;
top: 0;
-
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
z-index: 1;
background-color: var(--color-background2);
backdrop-filter: blur(36px);
+
+ & > div {
+ width: 100%;
+ }
}
.topBarRight {
@@ -19,8 +18,6 @@
flex-wrap: wrap;
flex: 1;
justify-content: flex-end;
-
- margin-right: 20px;
}
.textFilterContainer {
@@ -36,3 +33,79 @@
padding: 10px 40px;
}
}
+
+.groupsContainer {
+ &.doubleColumn {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+
+ @media screen and (min-width: 1200px) {
+ flex-direction: row;
+ align-items: flex-start;
+ }
+
+ .column {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+
+ @media screen and (max-width: 1199px) {
+ display: contents;
+ }
+ }
+
+ .group {
+ @media screen and (min-width: 1200px) {
+ padding: 10px 20px;
+ }
+ }
+ }
+}
+
+.tabsContainer {
+ display: flex;
+ align-items: center;
+ background-color: var(--color-bg-sidebar);
+ border-radius: 12px;
+ padding: 4px;
+}
+
+.tab {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ user-select: none;
+ font-size: 1em;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ transition: all 0.2s ease;
+ user-select: none;
+
+ &:hover {
+ color: var(--color-text-primary);
+ background: var(--bg-near-transparent);
+ }
+
+ &.active {
+ background-color: var(--color-focus-blue);
+ color: #fff;
+ box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
+ }
+}
+
+.tabCount {
+ font-family: var(--font-normal);
+ font-size: 0.7em;
+ margin-left: 6px;
+ padding: 2px 8px;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ background-color: rgba(255, 255, 255, 0.15);
+ border-radius: 10px;
+ font-weight: 600;
+ min-width: 20px;
+}
diff --git a/src/components/proxies/Proxies.tsx b/src/components/proxies/Proxies.tsx
index 7c6fd48..c04298c 100644
--- a/src/components/proxies/Proxies.tsx
+++ b/src/components/proxies/Proxies.tsx
@@ -1,4 +1,5 @@
import { Tooltip } from '@reach/tooltip';
+import cx from 'clsx';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,13 +8,13 @@ import ContentHeader from '~/components/ContentHeader';
import { ClosePrevConns } from '~/components/proxies/ClosePrevConns';
import { ProxyGroup } from '~/components/proxies/ProxyGroup';
import { ProxyPageFab } from '~/components/proxies/ProxyPageFab';
-import { ProxyProviderList } from '~/components/proxies/ProxyProviderList';
+import { ProxyProvider } from '~/components/proxies/ProxyProvider';
import Settings from '~/components/proxies/Settings';
import BaseModal from '~/components/shared/BaseModal';
import { TextFilter } from '~/components/shared/TextFitler';
import { connect, useStoreActions } from '~/components/StateProvider';
import Equalizer from '~/components/svg/Equalizer';
-import { getClashAPIConfig } from '~/store/app';
+import { getClashAPIConfig, getProxiesLayout } from '~/store/app';
import {
fetchProxies,
getDelay,
@@ -26,7 +27,7 @@ import type { State } from '~/store/types';
import s0 from './Proxies.module.scss';
-const { useState, useEffect, useCallback, useRef } = React;
+const { useState, useEffect, useCallback, useRef, useMemo } = React;
function Proxies({
dispatch,
@@ -35,9 +36,12 @@ function Proxies({
proxyProviders,
apiConfig,
showModalClosePrevConns,
+ proxiesLayout,
}) {
const refFetchedTimestamp = useRef<{ startAt?: number; completeAt?: number }>({});
+ const formatQty = (qty: number) => (qty < 100 ? '' + qty : '99+');
+
const fetchProxiesHooked = useCallback(() => {
refFetchedTimestamp.current.startAt = Date.now();
dispatch(fetchProxies(apiConfig)).then(() => {
@@ -66,45 +70,123 @@ function Proxies({
setIsSettingsModalOpen(false);
}, []);
+ const [activeTab, setActiveTab] = useState('proxies');
+
+ const handleTabKeyDown = useCallback(
+ (tab: string) => (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ setActiveTab(tab);
+ }
+ },
+ []
+ );
+
const {
proxies: { closeModalClosePrevConns, closePrevConnsAndTheModal },
} = useStoreActions();
const { t } = useTranslation();
+ const proxyGroups = useMemo(() => {
+ const formatted = groupNames.map((name, i) => ({ name, i }));
+ if (proxiesLayout !== 'double') return [formatted];
+ const left = [];
+ const right = [];
+ formatted.forEach((item, i) => {
+ if (i % 2 === 0) left.push(item);
+ else right.push(item);
+ });
+ return [left, right];
+ }, [groupNames, proxiesLayout]);
+
+ const providers = useMemo(() => {
+ const formatted = proxyProviders.map((item, i) => ({ item, i }));
+ if (proxiesLayout !== 'double') return [formatted];
+ const left = [];
+ const right = [];
+ formatted.forEach((item, i) => {
+ if (i % 2 === 0) left.push(item);
+ else right.push(item);
+ });
+ return [left, right];
+ }, [proxyProviders, proxiesLayout]);
+
return (
<>
<BaseModal isOpen={isSettingsModalOpen} onRequestClose={closeSettingsModal}>
<Settings />
</BaseModal>
<div className={s0.topBar}>
- <ContentHeader title={t('Proxies')} />
- <div className={s0.topBarRight}>
- <div className={s0.textFilterContainer}>
- <TextFilter textAtom={proxyFilterText} placeholder={t('Search')} />
+ <ContentHeader>
+ <div className={s0.tabsContainer}>
+ <div
+ className={cx(s0.tab, { [s0.active]: activeTab === 'proxies' })}
+ onClick={() => setActiveTab('proxies')}
+ onKeyDown={handleTabKeyDown('proxies')}
+ role="button"
+ tabIndex={0}
+ >
+ {t('Proxies')}
+ <span className={s0.tabCount}>{formatQty(groupNames.length)}</span>
+ </div>
+ {proxyProviders.length > 0 && (
+ <div
+ className={cx(s0.tab, { [s0.active]: activeTab === 'providers' })}
+ onClick={() => setActiveTab('providers')}
+ onKeyDown={handleTabKeyDown('providers')}
+ role="button"
+ tabIndex={0}
+ >
+ {t('proxy_provider')}
+ <span className={s0.tabCount}>{formatQty(proxyProviders.length)}</span>
+ </div>
+ )}
</div>
- <Tooltip label={t('settings')}>
- <Button kind="minimal" onClick={() => setIsSettingsModalOpen(true)}>
- <Equalizer size={16} />
- </Button>
- </Tooltip>
- </div>
- </div>
- <div>
- {groupNames.map((groupName: string) => {
- return (
- <div className={s0.group} key={groupName}>
- <ProxyGroup
- name={groupName}
- delay={delay}
- apiConfig={apiConfig}
- dispatch={dispatch}
- />
+ <div style={{ flex: 1 }} />
+ <div className={s0.topBarRight}>
+ <div className={s0.textFilterContainer}>
+ <TextFilter textAtom={proxyFilterText} placeholder={t('Search')} />
</div>
- );
- })}
+ <Tooltip label={t('settings')}>
+ <Button kind="minimal" onClick={() => setIsSettingsModalOpen(true)}>
+ <Equalizer size={16} />
+ </Button>
+ </Tooltip>
+ </div>
+ </ContentHeader>
</div>
- <ProxyProviderList items={proxyProviders} />
+ {activeTab === 'proxies' ? (
+ <div className={cx(s0.groupsContainer, { [s0.doubleColumn]: proxiesLayout === 'double' })}>
+ {proxyGroups.map((column, i) => (
+ <div key={i} className={s0.column}>
+ {column.map(({ name, i: originalIndex }) => (
+ <div className={s0.group} key={name} style={{ order: originalIndex }}>
+ <ProxyGroup name={name} delay={delay} apiConfig={apiConfig} dispatch={dispatch} />
+ </div>
+ ))}
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className={cx(s0.groupsContainer, { [s0.doubleColumn]: proxiesLayout === 'double' })}>
+ {providers.map((column, i) => (
+ <div key={i} className={s0.column}>
+ {column.map(({ item, i: originalIndex }) => (
+ <div className={s0.group} key={item.name} style={{ order: originalIndex }}>
+ <ProxyProvider
+ name={item.name}
+ proxies={item.proxies}
+ type={item.type}
+ vehicleType={item.vehicleType}
+ updatedAt={item.updatedAt}
+ subscriptionInfo={item.subscriptionInfo}
+ />
+ </div>
+ ))}
+ </div>
+ ))}
+ </div>
+ )}
<div style={{ height: 60 }} />
<ProxyPageFab dispatch={dispatch} apiConfig={apiConfig} proxyProviders={proxyProviders} />
<BaseModal isOpen={showModalClosePrevConns} onRequestClose={closeModalClosePrevConns}>
@@ -123,6 +205,7 @@ const mapState = (s: State) => ({
proxyProviders: getProxyProviders(s),
delay: getDelay(s),
showModalClosePrevConns: getShowModalClosePrevConns(s),
+ proxiesLayout: getProxiesLayout(s),
});
export default connect(mapState)(Proxies);
diff --git a/src/components/proxies/Proxy.module.scss b/src/components/proxies/Proxy.module.scss
index b1520eb..60c589f 100644
--- a/src/components/proxies/Proxy.module.scss
+++ b/src/components/proxies/Proxy.module.scss
@@ -31,10 +31,12 @@
}
background-color: var(--color-bg-proxy);
+ color: var(--color-text);
&.now {
- background-color: var(--color-focus-blue);
- color: #ddd;
+ background-color: var(--color-sb-active-row-bg);
+ color: var(--color-sb-active-row-font);
+ border-color: var(--color-focus-blue);
}
&.error {
@@ -55,14 +57,27 @@
@media (--breakpoint-not-small) {
font-size: 0.7em;
}
+ color: #f596aa;
+ opacity: 0.6;
+
+ .now & {
+ color: inherit;
+ opacity: 0.8;
+ }
}
.udpType {
font-family: var(--font-mono);
font-size: 0.6em;
- margin-right: 3px;
@media (--breakpoint-not-small) {
font-size: 0.7em;
}
+ color: #51a8dd;
+ opacity: 0.6;
+
+ .now & {
+ color: inherit;
+ opacity: 0.8;
+ }
}
.tfoType {
padding: 2px;
diff --git a/src/components/proxies/Proxy.tsx b/src/components/proxies/Proxy.tsx
index 1ba966e..578b8c6 100644
--- a/src/components/proxies/Proxy.tsx
+++ b/src/components/proxies/Proxy.tsx
@@ -4,7 +4,7 @@ import * as React from 'react';
import { keyCodes } from '~/misc/keycode';
import { getClashAPIConfig, getLatencyTestUrl } from '~/store/app';
-import { DispatchFn, ProxyItem } from '~/store/types';
+import { DelayMapping, DispatchFn, ProxiesMapping, ProxyItem } from '~/store/types';
import { ClashAPIConfig } from '~/types';
import { getDelay, getProxies, healthcheckProxy } from '../../store/proxies';
@@ -237,17 +237,14 @@ function ProxyImpl({
<ProxyNameTooltip label={name} aria-label={`proxy name: ${name}`}>
<span>{name}</span>
</ProxyNameTooltip>
- <span className={s0.proxyType} style={{ paddingLeft: 4, opacity: 0.6, color: '#51A8DD' }}>
+ <span className={s0.udpType} style={{ paddingLeft: 4 }}>
{formatUdpType(proxy.udp, proxy.xudp)}
</span>
</div>
<div className={s0.row}>
<div className={s0.row}>
- <span
- className={s0.proxyType}
- style={{ paddingRight: 4, opacity: 0.6, color: '#F596AA' }}
- >
+ <span className={s0.proxyType} style={{ paddingRight: 4 }}>
{formatProxyType(proxy.type)}
</span>
@@ -266,6 +263,33 @@ function ProxyImpl({
);
}
+function getLatency(
+ proxies: ProxiesMapping,
+ delay: DelayMapping,
+ name: string,
+ visited = new Set<string>()
+) {
+ if (visited.has(name)) return undefined;
+ visited.add(name);
+
+ const latency = delay[name];
+ if (latency && (latency.testing || typeof latency.number === 'number' || latency.error)) {
+ return latency;
+ }
+
+ const proxy = proxies[name];
+ if (proxy && proxy.now && proxies[proxy.now]) {
+ return getLatency(proxies, delay, proxy.now, visited);
+ }
+
+ const delayFromHistory = proxy?.history?.[proxy.history.length - 1]?.delay;
+ if (typeof delayFromHistory === 'number' && delayFromHistory > 0) {
+ return { number: delayFromHistory };
+ }
+
+ return latency;
+}
+
const mapState = (s: any, { name }) => {
const proxies = getProxies(s);
const delay = getDelay(s);
@@ -273,7 +297,7 @@ const mapState = (s: any, { name }) => {
const proxy = proxies[name] || { name, history: [] };
return {
proxy: proxy,
- latency: delay[name],
+ latency: getLatency(proxies, delay, name),
httpsLatencyTest: latencyTestUrl.startsWith('https://'),
apiConfig: getClashAPIConfig(s),
};
diff --git a/src/components/proxies/ProxyGroup.module.scss b/src/components/proxies/ProxyGroup.module.scss
index 16fcba6..e88d0c6 100644
--- a/src/components/proxies/ProxyGroup.module.scss
+++ b/src/components/proxies/ProxyGroup.module.scss
@@ -4,9 +4,15 @@
.group {
padding: 10px;
- background-color: var(--color-bg-card);
- border-radius: 10px;
- box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
+ background-color: var(--bg-log-info-card);
+ border: 1px solid var(--color-separator);
+ border-radius: 12px;
+ box-shadow: var(--shadow-card);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ }
}
.zapWrapper {
@@ -30,3 +36,22 @@
outline: var(--color-focus-blue) solid 1px;
}
}
+
+.groupHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ user-select: none;
+
+ .btnGroup {
+ display: flex;
+ flex-direction: row-reverse;
+ }
+
+ @media screen and (min-width: 768px) {
+ justify-content: flex-start;
+ .btnGroup {
+ flex-direction: row;
+ }
+ }
+}
diff --git a/src/components/proxies/ProxyGroup.tsx b/src/components/proxies/ProxyGroup.tsx
index 14c2f61..a892943 100644
--- a/src/components/proxies/ProxyGroup.tsx
+++ b/src/components/proxies/ProxyGroup.tsx
@@ -14,13 +14,14 @@ import {
import { fetchProxies, getProxies, switchProxy } from '~/store/proxies';
import Button from '../Button';
+import Collapsible from '../Collapsible';
import CollapsibleSectionHeader from '../CollapsibleSectionHeader';
import { connect, useStoreActions } from '../StateProvider';
import { useFilteredAndSorted } from './hooks';
import s0 from './ProxyGroup.module.scss';
import { ProxyList, ProxyListSummaryView } from './ProxyList';
-const { createElement, useCallback, useMemo, useState, useEffect } = React;
+const { useCallback, useMemo, useState } = React;
function ZapWrapper() {
return (
@@ -86,79 +87,47 @@ function ProxyGroupImpl({
setIsTestingLatency(false);
}, [all, apiConfig, dispatch, name, version.meta, latencyTestUrl, requestDelayForProxies]);
- const [windowWidth, setWindowWidth] = useState(window.innerWidth);
-
- const updateWindowWidth = () => {
- setWindowWidth(window.innerWidth);
- };
-
- useEffect(() => {
- window.addEventListener('resize', updateWindowWidth);
- return () => window.removeEventListener('resize', updateWindowWidth);
- }, []);
-
return (
<div className={s0.group}>
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: windowWidth > 768 ? 'start' : 'space-between',
- }}
- >
+ <div className={s0.groupHeader}>
<CollapsibleSectionHeader name={name} type={type} toggle={toggle} qty={all.length} />
- <div style={{ display: 'flex' }}>
- {windowWidth > 768 ? (
- <>
- <Button
- kind="minimal"
- onClick={toggle}
- className={s0.btn}
- title="Toggle collapsible section"
- >
- <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}>
- <ChevronDown size={20} />
- </span>
- </Button>
- <Button
- title="Test latency"
- kind="minimal"
- onClick={testLatency}
- isLoading={isTestingLatency}
- >
- <ZapWrapper />
- </Button>
- </>
- ) : (
- <>
- <Button
- title="Test latency"
- kind="minimal"
- onClick={testLatency}
- isLoading={isTestingLatency}
- >
- <ZapWrapper />
- </Button>
- <Button
- kind="minimal"
- onClick={toggle}
- className={s0.btn}
- title="Toggle collapsible section"
- >
- <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}>
- <ChevronDown size={20} />
- </span>
- </Button>
- </>
- )}
+ <div className={s0.btnGroup}>
+ <Button
+ kind="minimal"
+ onClick={toggle}
+ className={s0.btn}
+ title="Toggle collapsible section"
+ >
+ <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}>
+ <ChevronDown size={20} />
+ </span>
+ </Button>
+ <Button
+ title="Test latency"
+ kind="minimal"
+ onClick={testLatency}
+ isLoading={isTestingLatency}
+ >
+ <ZapWrapper />
+ </Button>
</div>
</div>
- {createElement(isOpen ? ProxyList : ProxyListSummaryView, {
- all,
- now,
- isSelectable,
- itemOnTapCallback,
- })}
+ <Collapsible isOpen={isOpen}>
+ <ProxyList
+ all={all}
+ now={now}
+ isSelectable={isSelectable}
+ itemOnTapCallback={itemOnTapCallback}
+ />
+ </Collapsible>
+ <Collapsible isOpen={!isOpen}>
+ <ProxyListSummaryView
+ all={all}
+ now={now}
+ isSelectable={isSelectable}
+ itemOnTapCallback={itemOnTapCallback}
+ />
+ </Collapsible>
</div>
);
}
diff --git a/src/components/proxies/ProxyLatency.module.scss b/src/components/proxies/ProxyLatency.module.scss
index 5063338..fce0a2e 100644
--- a/src/components/proxies/ProxyLatency.module.scss
+++ b/src/components/proxies/ProxyLatency.module.scss
@@ -11,7 +11,7 @@
border: 1px solid var(--color-proxy-border);
/* Use theme-aware latency background with sensible default */
background: var(--bg-latency, #ffffff);
- color: var(--color-text);
+ color: inherit;
font-size: 0.75em;
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease,
color 0.15s ease, transform 0.15s ease;
diff --git a/src/components/proxies/ProxyProvider.module.scss b/src/components/proxies/ProxyProvider.module.scss
index 582c255..282fb72 100644
--- a/src/components/proxies/ProxyProvider.module.scss
+++ b/src/components/proxies/ProxyProvider.module.scss
@@ -14,9 +14,15 @@
margin: 10px 40px;
}
padding: 10px;
- background-color: var(--color-bg-card);
- border-radius: 10px;
- box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
+ background-color: var(--bg-log-info-card);
+ border: 1px solid var(--color-separator);
+ border-radius: 12px;
+ box-shadow: var(--shadow-card);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ }
}
.actionFooter {
diff --git a/src/components/proxies/ProxyProvider.tsx b/src/components/proxies/ProxyProvider.tsx
index 99d3b3a..768214c 100644
--- a/src/components/proxies/ProxyProvider.tsx
+++ b/src/components/proxies/ProxyProvider.tsx
@@ -102,6 +102,7 @@ function ProxyProviderImpl({
alignItems: 'center',
flexWrap: 'wrap',
justifyContent: 'space-between',
+ userSelect: 'none',
}}
>
<CollapsibleSectionHeader
diff --git a/src/components/proxies/ProxyProviderList.tsx b/src/components/proxies/ProxyProviderList.tsx
index 22786f2..177b4d8 100644
--- a/src/components/proxies/ProxyProviderList.tsx
+++ b/src/components/proxies/ProxyProviderList.tsx
@@ -1,29 +1,23 @@
import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-import ContentHeader from '~/components/ContentHeader';
import { ProxyProvider } from '~/components/proxies/ProxyProvider';
import { FormattedProxyProvider } from '~/store/types';
export function ProxyProviderList({ items }: { items: FormattedProxyProvider[] }) {
- const { t } = useTranslation();
if (items.length === 0) return null;
return (
- <>
- <ContentHeader title={t('proxy_provider')} />
- <div>
- {items.map((item) => (
- <ProxyProvider
- key={item.name}
- name={item.name}
- proxies={item.proxies}
- type={item.type}
- vehicleType={item.vehicleType}
- updatedAt={item.updatedAt}
- subscriptionInfo={item.subscriptionInfo}
- />
- ))}
- </div>
- </>
+ <div>
+ {items.map((item) => (
+ <ProxyProvider
+ key={item.name}
+ name={item.name}
+ proxies={item.proxies}
+ type={item.type}
+ vehicleType={item.vehicleType}
+ updatedAt={item.updatedAt}
+ subscriptionInfo={item.subscriptionInfo}
+ />
+ ))}
+ </div>
);
}
diff --git a/src/components/proxies/Settings.tsx b/src/components/proxies/Settings.tsx
index b2ec192..dd2dfca 100644
--- a/src/components/proxies/Settings.tsx
+++ b/src/components/proxies/Settings.tsx
@@ -3,7 +3,12 @@ import { useTranslation } from 'react-i18next';
import Select from '~/components/shared/Select';
-import { getAutoCloseOldConns, getHideUnavailableProxies, getProxySortBy } from '../../store/app';
+import {
+ getAutoCloseOldConns,
+ getHideUnavailableProxies,
+ getProxiesLayout,
+ getProxySortBy,
+} from '../../store/app';
import { connect, useStoreActions } from '../StateProvider';
import Switch from '../SwitchThemed';
import s from './Settings.module.scss';
@@ -72,6 +77,16 @@ function Settings({ appConfig }) {
/>
</div>
</div>
+ <div className={s.labeledInput}>
+ <span>{t('double_column_layout')}</span>
+ <div>
+ <Switch
+ name="proxiesLayout"
+ checked={appConfig.proxiesLayout === 'double'}
+ onChange={(v) => updateAppConfig('proxiesLayout', v ? 'double' : 'single')}
+ />
+ </div>
+ </div>
</>
);
}
@@ -80,12 +95,14 @@ const mapState = (s) => {
const proxySortBy = getProxySortBy(s);
const hideUnavailableProxies = getHideUnavailableProxies(s);
const autoCloseOldConns = getAutoCloseOldConns(s);
+ const proxiesLayout = getProxiesLayout(s);
return {
appConfig: {
proxySortBy,
hideUnavailableProxies,
autoCloseOldConns,
+ proxiesLayout,
},
};
};
diff --git a/src/components/rules/RuleProviderItem.module.scss b/src/components/rules/RuleProviderItem.module.scss
index 8c8c070..933fa08 100644
--- a/src/components/rules/RuleProviderItem.module.scss
+++ b/src/components/rules/RuleProviderItem.module.scss
@@ -2,14 +2,11 @@
display: flex;
align-items: center;
height: 100%;
- padding: 12px 16px;
- border-radius: 8px;
- border: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05));
+ padding: 16px 20px;
transition: all 0.2s ease;
&:hover {
- border-color: var(--color-focus-blue);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ background: var(--bg-near-transparent);
.refreshButton {
opacity: 1;
@@ -21,9 +18,9 @@
width: 32px;
flex-shrink: 0;
color: var(--color-text-secondary);
- font-size: 0.8rem;
- opacity: 0.5;
- font-family: 'Roboto Mono', monospace;
+ font-size: 11px;
+ opacity: 0.4;
+ font-family: var(--font-mono);
}
.middle {
@@ -42,9 +39,9 @@
}
.name {
- font-size: 1rem;
+ font-size: 14px;
font-weight: 600;
- color: var(--color-text);
+ color: var(--color-text-highlight);
}
.badgeGroup {
@@ -56,9 +53,9 @@
display: flex;
align-items: center;
gap: 4px;
- font-size: 0.7rem;
+ font-size: 10px;
color: var(--color-text-secondary);
- background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
+ background: var(--bg-near-transparent);
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
@@ -69,7 +66,7 @@
display: flex;
align-items: center;
gap: 8px;
- font-size: 0.8rem;
+ font-size: 11px;
color: var(--color-text-secondary);
}
@@ -86,10 +83,12 @@
transition: all 0.2s ease;
padding: 8px !important;
border-radius: 50% !important;
+ color: var(--color-text-secondary);
&:hover {
opacity: 1;
- background: var(--color-bg-secondary, rgba(0, 0, 0, 0.1)) !important;
+ color: var(--color-focus-blue);
+ background: var(--bg-near-transparent) !important;
}
}