* 'dev' of https://github.com/vffuunnyy/clash-verge-rev-lite:
  the Add Profile button has been moved, and the layout has been slightly changed.
  the connections page has been slightly revised.
  fixed an issue with the dialog box when the profile name is long.
  added glass effect to components
  fixed icon background
This commit is contained in:
vffuunnyy
2025-08-16 01:57:32 +07:00
9 changed files with 1083 additions and 853 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -1,15 +1,14 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import React, { useMemo, useState, useEffect, RefObject } from "react";
import React, { useMemo, useState, useEffect, useRef } from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
Row,
Header,
ColumnSizingState,
} from "@tanstack/react-table";
import { TableVirtuoso, TableComponents } from "react-virtuoso";
import {
Table,
@@ -27,7 +26,30 @@ import { cn } from "@root/lib/utils";
dayjs.extend(relativeTime);
// Интерфейс для строки данных, которую использует react-table
interface IConnectionsItem {
id: string;
metadata: {
host: string;
destinationIP: string;
destinationPort: string;
remoteDestination: string;
process?: string;
processPath?: string;
sourceIP: string;
sourcePort: string;
type: string;
network: string;
};
rule: string;
rulePayload?: string;
chains: string[];
download: number;
upload: number;
curDownload?: number;
curUpload?: number;
start: string;
}
interface ConnectionRow {
id: string;
host: string;
@@ -45,29 +67,81 @@ interface ConnectionRow {
connectionData: IConnectionsItem;
}
// Интерфейс для пропсов, которые компонент получает от родителя
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
scrollerRef: (element: HTMLElement | Window | null) => void;
}
const ColumnResizer = ({ header }: { header: Header<ConnectionRow, unknown> }) => {
return (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none",
"bg-transparent hover:bg-primary/50 active:bg-primary",
"transition-colors duration-150",
header.column.getIsResizing() && "bg-primary"
)}
style={{
transform: header.column.getIsResizing() ? `translateX(0px)` : "",
}}
/>
);
};
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail, scrollerRef } = props;
const tableContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (tableContainerRef.current && scrollerRef) {
scrollerRef(tableContainerRef.current);
}
}, [scrollerRef]);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
try {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
return saved
? JSON.parse(saved)
: {
host: 220,
download: 88,
upload: 88,
dlSpeed: 88,
ulSpeed: 88,
chains: 340,
rule: 280,
process: 220,
time: 120,
source: 200,
remoteDestination: 200,
type: 160,
};
} catch {
return {};
return {
host: 220,
download: 88,
upload: 88,
dlSpeed: 88,
ulSpeed: 88,
chains: 340,
rule: 280,
process: 220,
time: 120,
source: 200,
remoteDestination: 200,
type: 160,
};
}
});
useEffect(() => {
localStorage.setItem(
"connection-table-widths",
JSON.stringify(columnSizing),
JSON.stringify(columnSizing)
);
}, [columnSizing]);
@@ -107,13 +181,16 @@ export const ConnectionTable = (props: Props) => {
header: () => t("Host"),
size: columnSizing?.host || 220,
minSize: 180,
maxSize: 400,
},
{
accessorKey: "download",
header: () => t("Downloaded"),
size: columnSizing?.download || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
@@ -122,8 +199,10 @@ export const ConnectionTable = (props: Props) => {
accessorKey: "upload",
header: () => t("Uploaded"),
size: columnSizing?.upload || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
@@ -132,8 +211,10 @@ export const ConnectionTable = (props: Props) => {
accessorKey: "dlSpeed",
header: () => t("DL Speed"),
size: columnSizing?.dlSpeed || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
@@ -142,8 +223,10 @@ export const ConnectionTable = (props: Props) => {
accessorKey: "ulSpeed",
header: () => t("UL Speed"),
size: columnSizing?.ulSpeed || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
@@ -153,26 +236,30 @@ export const ConnectionTable = (props: Props) => {
header: () => t("Chains"),
size: columnSizing?.chains || 340,
minSize: 180,
maxSize: 500,
},
{
accessorKey: "rule",
header: () => t("Rule"),
size: columnSizing?.rule || 280,
minSize: 180,
maxSize: 400,
},
{
accessorKey: "process",
header: () => t("Process"),
size: columnSizing?.process || 220,
minSize: 180,
maxSize: 350,
},
{
accessorKey: "time",
header: () => t("Time"),
size: columnSizing?.time || 120,
minSize: 100,
maxSize: 180,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{dayjs(getValue<string>()).fromNow()}
</div>
),
@@ -182,21 +269,24 @@ export const ConnectionTable = (props: Props) => {
header: () => t("Source"),
size: columnSizing?.source || 200,
minSize: 130,
maxSize: 300,
},
{
accessorKey: "remoteDestination",
header: () => t("Destination"),
size: columnSizing?.remoteDestination || 200,
minSize: 130,
maxSize: 300,
},
{
accessorKey: "type",
header: () => t("Type"),
size: columnSizing?.type || 160,
minSize: 100,
maxSize: 220,
},
],
[columnSizing],
[columnSizing]
);
const table = useReactTable({
@@ -206,92 +296,91 @@ export const ConnectionTable = (props: Props) => {
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
enableColumnResizing: true,
});
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(
() => ({
// Явно типизируем `ref` для каждого компонента
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
<div className="h-full" {...props} ref={ref} />
)),
Table: (props) => <Table {...props} className="w-full border-collapse" />,
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableHeader {...props} ref={ref} />
)),
// Явно типизируем пропсы и `ref` для TableRow
TableRow: React.forwardRef<
HTMLTableRowElement,
{ item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>
>(({ item: row, ...props }, ref) => {
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
// Больше не нужно искать ее по индексу!
const totalTableWidth = useMemo(() => {
return table.getCenterTotalSize();
}, [table.getState().columnSizing]);
if (connRows.length === 0) {
return (
<TableRow
{...props}
ref={ref}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onShowDetail(row.original.connectionData)}
/>
);
}),
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
}),
[],
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{t("No connections")}</p>
</div>
);
}
return (
<div className="h-full rounded-md border overflow-hidden">
{connRows.length > 0 ? (
<TableVirtuoso
scrollerRef={scrollerRef}
data={table.getRowModel().rows}
components={VirtuosoTableComponents}
fixedHeaderContent={() =>
table.getHeaderGroups().map((headerGroup) => (
<div className="rounded-md border relative bg-background">
<Table
className="w-full border-collapse table-fixed"
style={{
width: totalTableWidth,
minWidth: "100%",
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="hover:bg-transparent bg-background/95 backdrop-blur"
className="hover:bg-transparent border-b-0 h-10"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className="p-2"
className={cn(
"sticky top-0 z-10",
"p-2 text-xs font-semibold select-none border-r last:border-r-0 bg-background h-10"
)}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize,
}}
>
<div className="flex items-center justify-between h-full">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
header.getContext()
)}
</div>
{header.column.getCanResize() && (
<ColumnResizer header={header} />
)}
</TableHead>
))}
</TableRow>
))
}
itemContent={(index, row) => (
<>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => onShowDetail(row.original.connectionData)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: cell.column.getSize() }}
className="p-2 whitespace-nowrap"
onClick={() => onShowDetail(row.original.connectionData)}
className="p-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm border-r last:border-r-0"
style={{
width: cell.column.getSize(),
minWidth: cell.column.columnDef.minSize,
maxWidth: cell.column.columnDef.maxSize,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</>
)}
/>
) : (
<div className="flex h-full items-center justify-center">
<p>No results.</p>
</div>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
// Новые импорты из shadcn/ui
import {
AlertDialog,
AlertDialogAction,
@@ -18,7 +17,7 @@ interface Props {
open: boolean;
title: string;
description: string;
onOpenChange: (open: boolean) => void; // shadcn использует этот коллбэк
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
@@ -30,7 +29,7 @@ export const ConfirmViewer = (props: Props) => {
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogTitle className="truncate">{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -23,7 +23,6 @@ import { open } from "@tauri-apps/plugin-shell";
import { ProxiesEditorViewer } from "./proxies-editor-viewer";
import { cn } from "@root/lib/utils";
// --- Компоненты shadcn/ui ---
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
@@ -46,7 +45,6 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
// --- Иконки ---
import {
GripVertical,
File as FileIcon,
@@ -71,10 +69,8 @@ import {
} from "lucide-react";
import { t } from "i18next";
// Активируем плагин для dayjs
dayjs.extend(relativeTime);
// --- Вспомогательные функции ---
const parseUrl = (url?: string): string | undefined => {
if (!url) return undefined;
try {
@@ -302,6 +298,11 @@ export const ProfileItem = (props: Props) => {
isDestructive: true,
};
const MAX_NAME_LENGTH = 25;
const truncatedName = name.length > MAX_NAME_LENGTH
? `${name.slice(0, MAX_NAME_LENGTH)}...`
: name;
return (
<div ref={setNodeRef} style={style} {...attributes}>
<ContextMenu>
@@ -459,7 +460,6 @@ export const ProfileItem = (props: Props) => {
</ContextMenuContent>
</ContextMenu>
{/* Модальные окна для редактирования */}
{fileOpen && (
<EditorViewer
open={true}
@@ -479,10 +479,10 @@ export const ProfileItem = (props: Props) => {
<RulesEditorViewer
open={true}
onClose={() => setRulesOpen(false)}
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
profileUid={uid}
property={option?.rules ?? ""}
groupsUid={option?.groups ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
groupsUid={option?.groups ?? ""}
mergeUid={option?.merge ?? ""}
onSave={onSave}
/>
)}
@@ -491,7 +491,7 @@ export const ProfileItem = (props: Props) => {
<ProxiesEditorViewer
open={true}
onClose={() => setProxiesOpen(false)}
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
profileUid={uid}
property={option?.proxies ?? ""}
onSave={onSave}
/>
@@ -501,10 +501,10 @@ export const ProfileItem = (props: Props) => {
<GroupsEditorViewer
open={true}
onClose={() => setGroupsOpen(false)}
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
profileUid={uid}
property={option?.groups ?? ""}
proxiesUid={option?.proxies ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
proxiesUid={option?.proxies ?? ""}
mergeUid={option?.merge ?? ""}
onSave={onSave}
/>
)}
@@ -513,7 +513,7 @@ export const ProfileItem = (props: Props) => {
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={onDelete}
title={t("Delete Profile", { name })}
title={t("Delete Profile", { name: truncatedName })}
description={t("This action cannot be undone.")}
/>
</div>

View File

@@ -40,7 +40,11 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1",
"backdrop-blur-sm bg-white/70 border border-white/40",
"dark:bg-white/10 dark:border-white/20",
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -72,7 +76,13 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:hover:bg-red-200/80 data-[variant=destructive]:focus:bg-red-200/80",
"dark:data-[variant=destructive]:hover:bg-destructive/20 dark:data-[variant=destructive]:focus:bg-destructive/20",
"data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -90,7 +100,10 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -126,7 +139,10 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -154,6 +170,7 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
"text-foreground/80",
className,
)}
{...props}
@@ -168,7 +185,11 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
className={cn(
"-mx-1 my-1 h-px",
"bg-gray-400/50 dark:bg-white/20",
className
)}
{...props}
/>
);
@@ -182,7 +203,8 @@ function DropdownMenuShortcut({
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-xs tracking-widest",
"text-foreground/60",
className,
)}
{...props}
@@ -209,7 +231,10 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"data-[state=open]:backdrop-blur-sm data-[state=open]:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20 dark:data-[state=open]:bg-white/20",
className,
)}
{...props}
@@ -228,7 +253,11 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md p-1",
"backdrop-blur-sm bg-white/70 border border-white/40",
"dark:bg-white/10 dark:border-white/20",
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}

View File

@@ -35,7 +35,11 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
className,
)}
{...props}
@@ -59,7 +63,11 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md",
"backdrop-blur-sm bg-white/70 border border-white/40",
"dark:bg-white/10 dark:border-white/20",
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -105,7 +113,11 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"transition-all duration-200",
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}

View File

@@ -49,18 +49,37 @@ import {
PauseCircle,
ArrowDown,
ArrowUp,
Menu,
} from "lucide-react";
import {SidebarTrigger} from "@/components/ui/sidebar";
interface IConnectionsItem {
id: string;
metadata: {
host: string;
destinationIP: string;
process?: string;
};
start?: string;
curUpload?: number;
curDownload?: number;
}
interface IConnections {
uploadTotal: number;
downloadTotal: number;
connections: IConnectionsItem[];
data: IConnectionsItem[];
}
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const initConn: IConnections = {
uploadTotal: 0,
downloadTotal: 0,
connections: [],
data: [],
};
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const ConnectionsPage = () => {
const { t } = useTranslation();
const pageVisible = useVisibility();
@@ -77,9 +96,9 @@ const ConnectionsPage = () => {
new Date(b.start || "0").getTime()! -
new Date(a.start || "0").getTime()!,
),
"Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!),
"Upload Speed": (list) => list.sort((a, b) => (b.curUpload ?? 0) - (a.curUpload ?? 0)),
"Download Speed": (list) =>
list.sort((a, b) => b.curDownload! - a.curDownload!),
list.sort((a, b) => (b.curDownload ?? 0) - (a.curDownload ?? 0)),
};
const [isPaused, setIsPaused] = useState(false);
@@ -91,6 +110,7 @@ const ConnectionsPage = () => {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data,
data: connections.data,
};
if (isPaused) return frozenData ?? currentData;
return currentData;
@@ -147,6 +167,7 @@ const ConnectionsPage = () => {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data,
data: connections.data,
});
} else {
setFrozenData(null);
@@ -155,13 +176,13 @@ const ConnectionsPage = () => {
});
}, [connections]);
const headerHeight = "7rem";
return (
<div className="h-full w-full relative">
<div className="relative h-full w-full">
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
)}
className="absolute top-0 left-0 right-0 z-20 p-4 bg-background/80 backdrop-blur-sm"
style={{ height: headerHeight }}
>
<div className="flex justify-between items-center">
<div className="w-10">
@@ -255,11 +276,15 @@ const ConnectionsPage = () => {
</div>
</div>
<div className="absolute top-0 left-0 right-0 bottom-0 pt-28">
<div
ref={scrollerRefCallback}
className="absolute left-0 right-0 bottom-0 overflow-y-auto"
style={{ top: headerHeight }}
>
{filterConn.length === 0 ? (
<BaseEmpty />
) : isTableLayout ? (
<div className="p-4 pt-0 h-full w-full">
<div className="p-4 pt-0">
<ConnectionTable
connections={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)}

View File

@@ -90,7 +90,6 @@ const MinimalHomePage: React.FC = () => {
}
}, [profileItems, activateProfile]);
const handleProfileChange = useLockFn(async (uid: string) => {
if (profiles?.current === uid) return;
await activateProfile(uid, true);
@@ -204,12 +203,47 @@ const MinimalHomePage: React.FC = () => {
<SidebarTrigger />
</div>
<div className="justify-self-center flex flex-col items-center gap-2">
<div className="relative">
{profileItems.length > 0 && (
<div className="flex-shrink-0">
<div className="relative flex items-center justify-center">
{profileItems.length > 0 ? (
<>
<div className="absolute right-full mr-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => viewerRef.current?.create()}
className={cn(
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
"transition-all duration-200"
)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Add Profile")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full max-w-[250px] sm:max-w-xs">
<Button
variant="outline"
className={cn(
"w-full max-w-[250px] sm:max-w-xs",
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
"transition-all duration-200"
)}
>
<span className="truncate">{currentProfileName}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -228,17 +262,10 @@ const MinimalHomePage: React.FC = () => {
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => viewerRef.current?.create()}>
<PlusCircle className="mr-2 h-4 w-4" />
<span>{t("Add Profile")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{currentProfile?.type === 'remote' && (
<div className="absolute top-1/2 -translate-y-1/2 left-full ml-2">
<div className="absolute left-full ml-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -247,7 +274,14 @@ const MinimalHomePage: React.FC = () => {
size="icon"
onClick={handleUpdateProfile}
disabled={isUpdating}
className="flex-shrink-0"
className={cn(
"flex-shrink-0",
"backdrop-blur-sm bg-white/70 border border-gray-300/50",
"dark:bg-white/5 dark:border-white/10",
"hover:bg-white/85 hover:border-gray-400/60",
"dark:hover:bg-white/10 dark:hover:border-white/15",
"transition-all duration-200"
)}
>
{isUpdating ? <Loader2 className="h-5 w-5 animate-spin" /> : <RefreshCw className="h-5 w-5" />}
</Button>
@@ -257,6 +291,48 @@ const MinimalHomePage: React.FC = () => {
</TooltipProvider>
</div>
)}
</>
) : (
<>
<div className="absolute right-full mr-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => viewerRef.current?.create()}
className={cn(
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
"transition-all duration-200"
)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Add Profile")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button
variant="outline"
disabled
className={cn(
"max-w-[250px] sm:max-w-xs opacity-50 cursor-not-allowed",
"backdrop-blur-sm bg-white/50 border-gray-300/40",
"dark:bg-white/3 dark:border-white/10"
)}
>
<span className="truncate">{t("No profiles available")}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-30" />
</Button>
</>
)}
</div>
</div>
<div className="flex justify-end">

View File

@@ -154,7 +154,7 @@ interface IConnectionsItem {
start: string;
chains: string[];
rule: string;
rulePayload: string;
rulePayload?: string;
curUpload?: number; // upload speed, calculate at runtime
curDownload?: number; // download speed, calculate at runtime
}