mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 05:20:28 +08:00
feat(ui): introduce BaseSearchPanel popover and migrate connections search
This commit is contained in:
parent
28568cf728
commit
7d42850aa8
279
src/components/base/base-search-panel.tsx
Normal file
279
src/components/base/base-search-panel.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { CheckRounded, FilterListRounded } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
Popover,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { type ComponentProps, useRef } from "react";
|
||||||
|
|
||||||
|
import { BaseSearchBox } from "./base-search-box";
|
||||||
|
import { BaseStyledTextField } from "./base-styled-text-field";
|
||||||
|
|
||||||
|
export type BaseSearchPanelField<T extends string> = {
|
||||||
|
key: T;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseSearchBoxProps = ComponentProps<typeof BaseSearchBox>;
|
||||||
|
|
||||||
|
type BaseSearchPanelProps<T extends string> = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSearch: BaseSearchBoxProps["onSearch"];
|
||||||
|
searchBoxProps?: Omit<BaseSearchBoxProps, "onSearch" | "placeholder">;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
filterLabel?: string;
|
||||||
|
title?: string;
|
||||||
|
fields: BaseSearchPanelField<T>[];
|
||||||
|
activeField: T;
|
||||||
|
onActiveFieldChange: (field: T) => void;
|
||||||
|
options: string[];
|
||||||
|
isOptionSelected: (option: string) => boolean;
|
||||||
|
onToggleOption: (option: string) => void;
|
||||||
|
searchValue: string;
|
||||||
|
onSearchValueChange: (value: string) => void;
|
||||||
|
onSearchSubmit?: (value: string) => void;
|
||||||
|
emptyText?: string;
|
||||||
|
clearLabel?: string;
|
||||||
|
clearDisabled?: boolean;
|
||||||
|
onClear?: () => void;
|
||||||
|
showIndicator?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BaseSearchPanel = <T extends string>({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSearch,
|
||||||
|
searchBoxProps,
|
||||||
|
searchPlaceholder,
|
||||||
|
filterLabel,
|
||||||
|
title,
|
||||||
|
fields,
|
||||||
|
activeField,
|
||||||
|
onActiveFieldChange,
|
||||||
|
options,
|
||||||
|
isOptionSelected,
|
||||||
|
onToggleOption,
|
||||||
|
searchValue,
|
||||||
|
onSearchValueChange,
|
||||||
|
onSearchSubmit,
|
||||||
|
emptyText,
|
||||||
|
clearLabel,
|
||||||
|
clearDisabled,
|
||||||
|
onClear,
|
||||||
|
showIndicator,
|
||||||
|
}: BaseSearchPanelProps<T>) => {
|
||||||
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const handleToggleOpen = () => {
|
||||||
|
onOpenChange(!open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const anchorWidth = anchorRef.current?.clientWidth;
|
||||||
|
const placeholderProps = searchPlaceholder
|
||||||
|
? { placeholder: searchPlaceholder }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
ref={anchorRef}
|
||||||
|
sx={{ display: "flex", alignItems: "center", gap: 1, width: "100%" }}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<BaseSearchBox
|
||||||
|
onSearch={onSearch}
|
||||||
|
{...placeholderProps}
|
||||||
|
{...searchBoxProps}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title={filterLabel ?? ""}>
|
||||||
|
<Badge
|
||||||
|
color="primary"
|
||||||
|
variant="dot"
|
||||||
|
overlap="circular"
|
||||||
|
invisible={!showIndicator}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
onClick={handleToggleOpen}
|
||||||
|
aria-label={filterLabel}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<FilterListRounded />
|
||||||
|
</IconButton>
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorRef.current}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
mt: 1,
|
||||||
|
width: anchorWidth,
|
||||||
|
minWidth: 520,
|
||||||
|
maxWidth: "90vw",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2">{title}</Typography>
|
||||||
|
{onClear && clearLabel ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={clearDisabled}
|
||||||
|
>
|
||||||
|
{clearLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", minHeight: 260, maxHeight: 360 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 180,
|
||||||
|
borderRight: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={field.key}
|
||||||
|
selected={field.key === activeField}
|
||||||
|
onClick={() => onActiveFieldChange(field.key)}
|
||||||
|
sx={{ px: 1.25, py: 0.75 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
width: "100%",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{field.label}</Typography>
|
||||||
|
{field.count ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: 20,
|
||||||
|
px: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: "action.selected",
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{field.count}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BaseStyledTextField
|
||||||
|
value={searchValue}
|
||||||
|
{...placeholderProps}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSearchValueChange(event.target.value ?? "")
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" || !onSearchSubmit) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onSearchSubmit(searchValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflowY: "auto" }}>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ px: 1.5, py: 2, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List dense disablePadding>
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = isOptionSelected(option);
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={option}
|
||||||
|
selected={selected}
|
||||||
|
onClick={() => onToggleOption(option)}
|
||||||
|
sx={{ px: 1.5, py: 0.75 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
width: "100%",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" noWrap>
|
||||||
|
{option}
|
||||||
|
</Typography>
|
||||||
|
{selected ? (
|
||||||
|
<CheckRounded
|
||||||
|
fontSize="small"
|
||||||
|
sx={{ color: "primary.main" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,6 +5,10 @@ export { BaseFieldset } from "./base-fieldset";
|
|||||||
export { BaseLoading } from "./base-loading";
|
export { BaseLoading } from "./base-loading";
|
||||||
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
||||||
export { BasePage } from "./base-page";
|
export { BasePage } from "./base-page";
|
||||||
|
export {
|
||||||
|
BaseSearchPanel,
|
||||||
|
type BaseSearchPanelField,
|
||||||
|
} from "./base-search-panel";
|
||||||
export { BaseSearchBox, type SearchState } from "./base-search-box";
|
export { BaseSearchBox, type SearchState } from "./base-search-box";
|
||||||
export {
|
export {
|
||||||
BaseSplitChipEditor,
|
BaseSplitChipEditor,
|
||||||
|
|||||||
@ -1,28 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
DeleteForeverRounded,
|
DeleteForeverRounded,
|
||||||
FilterListRounded,
|
|
||||||
PauseCircleOutlineRounded,
|
PauseCircleOutlineRounded,
|
||||||
PlayCircleOutlineRounded,
|
PlayCircleOutlineRounded,
|
||||||
TableChartRounded,
|
TableChartRounded,
|
||||||
TableRowsRounded,
|
TableRowsRounded,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Fab,
|
Fab,
|
||||||
IconButton,
|
IconButton,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Popover,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Tooltip,
|
|
||||||
Zoom,
|
Zoom,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api";
|
import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api";
|
||||||
@ -30,8 +23,9 @@ import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api";
|
|||||||
import {
|
import {
|
||||||
BaseEmpty,
|
BaseEmpty,
|
||||||
BasePage,
|
BasePage,
|
||||||
BaseSearchBox,
|
BaseSearchPanel,
|
||||||
BaseStyledSelect,
|
BaseStyledSelect,
|
||||||
|
type BaseSearchPanelField,
|
||||||
type SearchState,
|
type SearchState,
|
||||||
} from "@/components/base";
|
} from "@/components/base";
|
||||||
import {
|
import {
|
||||||
@ -111,6 +105,8 @@ type ConnectionFilters = {
|
|||||||
destinationPort: string[];
|
destinationPort: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FilterField = keyof ConnectionFilters;
|
||||||
|
|
||||||
const EMPTY_FILTERS: ConnectionFilters = {
|
const EMPTY_FILTERS: ConnectionFilters = {
|
||||||
host: [],
|
host: [],
|
||||||
sourceIP: [],
|
sourceIP: [],
|
||||||
@ -120,6 +116,14 @@ const EMPTY_FILTERS: ConnectionFilters = {
|
|||||||
destinationPort: [],
|
destinationPort: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeFilterValue = (field: FilterField, value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
return field === "host" || field === "network"
|
||||||
|
? trimmed.toLowerCase()
|
||||||
|
: trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
const getUniqueValues = (values: Array<string | undefined>) => {
|
const getUniqueValues = (values: Array<string | undefined>) => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
values.forEach((value) => {
|
values.forEach((value) => {
|
||||||
@ -140,9 +144,10 @@ const ConnectionsPage = () => {
|
|||||||
"active",
|
"active",
|
||||||
);
|
);
|
||||||
const [filters, setFilters] = useState<ConnectionFilters>(EMPTY_FILTERS);
|
const [filters, setFilters] = useState<ConnectionFilters>(EMPTY_FILTERS);
|
||||||
const [filterAnchorEl, setFilterAnchorEl] = useState<HTMLElement | null>(
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
null,
|
const [activeFilterField, setActiveFilterField] =
|
||||||
);
|
useState<FilterField>("sourceIP");
|
||||||
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -196,6 +201,36 @@ const ConnectionsPage = () => {
|
|||||||
};
|
};
|
||||||
}, [baseConnections]);
|
}, [baseConnections]);
|
||||||
|
|
||||||
|
const filterFields = useMemo<BaseSearchPanelField<FilterField>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "sourceIP" as const,
|
||||||
|
label: t("connections.components.fields.sourceIP"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "destinationIP" as const,
|
||||||
|
label: t("connections.components.fields.destinationIP"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "host" as const,
|
||||||
|
label: t("connections.components.fields.host"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "network" as const,
|
||||||
|
label: t("connections.components.fields.network"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sourcePort" as const,
|
||||||
|
label: t("connections.components.fields.sourcePort"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "destinationPort" as const,
|
||||||
|
label: t("connections.components.fields.destinationPort"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const normalizedFilters = useMemo(
|
const normalizedFilters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
host: new Set(
|
host: new Set(
|
||||||
@ -222,6 +257,35 @@ const ConnectionsPage = () => {
|
|||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilterQuery("");
|
||||||
|
}, [activeFilterField]);
|
||||||
|
|
||||||
|
const activeFieldOptions = useMemo(() => {
|
||||||
|
const options = filterOptions[activeFilterField] ?? [];
|
||||||
|
const selected = filters[activeFilterField] ?? [];
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
selected.forEach((value) => {
|
||||||
|
const normalized = normalizeFilterValue(activeFilterField, value);
|
||||||
|
if (!normalized) return;
|
||||||
|
map.set(normalized, value.trim());
|
||||||
|
});
|
||||||
|
options.forEach((value) => {
|
||||||
|
const normalized = normalizeFilterValue(activeFilterField, value);
|
||||||
|
if (!normalized || map.has(normalized)) return;
|
||||||
|
map.set(normalized, value);
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [activeFilterField, filterOptions, filters]);
|
||||||
|
|
||||||
|
const visibleFieldOptions = useMemo(() => {
|
||||||
|
const query = filterQuery.trim().toLowerCase();
|
||||||
|
if (!query) return activeFieldOptions;
|
||||||
|
return activeFieldOptions.filter((option) =>
|
||||||
|
option.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [activeFieldOptions, filterQuery]);
|
||||||
|
|
||||||
const [filterConn] = useMemo(() => {
|
const [filterConn] = useMemo(() => {
|
||||||
const orderFunc = orderFunctionMap[curOrderOpt];
|
const orderFunc = orderFunctionMap[curOrderOpt];
|
||||||
let matchConns = baseConnections.filter((conn) => {
|
let matchConns = baseConnections.filter((conn) => {
|
||||||
@ -322,6 +386,43 @@ const ConnectionsPage = () => {
|
|||||||
|
|
||||||
const detailRef = useRef<ConnectionDetailRef>(null!);
|
const detailRef = useRef<ConnectionDetailRef>(null!);
|
||||||
|
|
||||||
|
const isValueSelected = useCallback(
|
||||||
|
(field: FilterField, value: string) =>
|
||||||
|
normalizedFilters[field].has(normalizeFilterValue(field, value)),
|
||||||
|
[normalizedFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleFilterValue = useCallback((field: FilterField, value: string) => {
|
||||||
|
const normalized = normalizeFilterValue(field, value);
|
||||||
|
if (!normalized) return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
setFilters((prev) => {
|
||||||
|
const current = prev[field] ?? [];
|
||||||
|
const next = current.filter(
|
||||||
|
(item) => normalizeFilterValue(field, item) !== normalized,
|
||||||
|
);
|
||||||
|
if (next.length === current.length) {
|
||||||
|
return { ...prev, [field]: [...current, trimmed] };
|
||||||
|
}
|
||||||
|
return { ...prev, [field]: next };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addFilterValue = useCallback((field: FilterField, value: string) => {
|
||||||
|
const normalized = normalizeFilterValue(field, value);
|
||||||
|
if (!normalized) return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
setFilters((prev) => {
|
||||||
|
const current = prev[field] ?? [];
|
||||||
|
if (
|
||||||
|
current.some((item) => normalizeFilterValue(field, item) === normalized)
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, [field]: [...current, trimmed] };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(matcher: (content: string) => boolean, state: SearchState) => {
|
(matcher: (content: string) => boolean, state: SearchState) => {
|
||||||
setMatch(() => matcher);
|
setMatch(() => matcher);
|
||||||
@ -332,16 +433,6 @@ const ConnectionsPage = () => {
|
|||||||
|
|
||||||
const hasTableData = filterConn.length > 0;
|
const hasTableData = filterConn.length > 0;
|
||||||
|
|
||||||
const handleFilterChange = useCallback(
|
|
||||||
(key: keyof ConnectionFilters) => (_: unknown, values: string[]) => {
|
|
||||||
const nextValues = Array.from(
|
|
||||||
new Set(values.map((value) => value.trim()).filter(Boolean)),
|
|
||||||
);
|
|
||||||
setFilters((prev) => ({ ...prev, [key]: nextValues }));
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
const handleClearFilters = useCallback(() => {
|
||||||
setFilters({ ...EMPTY_FILTERS });
|
setFilters({ ...EMPTY_FILTERS });
|
||||||
}, []);
|
}, []);
|
||||||
@ -460,158 +551,39 @@ const ConnectionsPage = () => {
|
|||||||
))}
|
))}
|
||||||
</BaseStyledSelect>
|
</BaseStyledSelect>
|
||||||
)}
|
)}
|
||||||
<Tooltip title={t("connections.components.actions.filter")}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Badge
|
<BaseSearchPanel
|
||||||
color="primary"
|
open={isFilterOpen}
|
||||||
variant="dot"
|
onOpenChange={setIsFilterOpen}
|
||||||
overlap="circular"
|
onSearch={handleSearch}
|
||||||
invisible={!hasActiveFilters}
|
filterLabel={t("connections.components.actions.filter")}
|
||||||
sx={{ mr: 1 }}
|
showIndicator={hasActiveFilters}
|
||||||
>
|
title={t("connections.components.actions.filter")}
|
||||||
<IconButton
|
fields={filterFields.map((field) => ({
|
||||||
size="small"
|
...field,
|
||||||
color="inherit"
|
count: filters[field.key].length,
|
||||||
onClick={(event) =>
|
}))}
|
||||||
setFilterAnchorEl((prev) => (prev ? null : event.currentTarget))
|
activeField={activeFilterField}
|
||||||
}
|
onActiveFieldChange={setActiveFilterField}
|
||||||
aria-label={t("connections.components.actions.filter")}
|
options={visibleFieldOptions}
|
||||||
>
|
isOptionSelected={(option) =>
|
||||||
<FilterListRounded />
|
isValueSelected(activeFilterField, option)
|
||||||
</IconButton>
|
}
|
||||||
</Badge>
|
onToggleOption={(option) =>
|
||||||
</Tooltip>
|
toggleFilterValue(activeFilterField, option)
|
||||||
<Popover
|
}
|
||||||
open={Boolean(filterAnchorEl)}
|
searchValue={filterQuery}
|
||||||
anchorEl={filterAnchorEl}
|
onSearchValueChange={setFilterQuery}
|
||||||
onClose={() => setFilterAnchorEl(null)}
|
onSearchSubmit={(value) => {
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
addFilterValue(activeFilterField, value);
|
||||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
setFilterQuery("");
|
||||||
>
|
}}
|
||||||
<Box sx={{ p: 2, width: 360 }}>
|
searchPlaceholder={t("shared.placeholders.filter")}
|
||||||
<Box
|
emptyText={t("shared.statuses.empty")}
|
||||||
sx={{
|
clearLabel={t("connections.components.actions.clearFilters")}
|
||||||
display: "flex",
|
onClear={handleClearFilters}
|
||||||
alignItems: "center",
|
clearDisabled={!hasActiveFilters}
|
||||||
justifyContent: "space-between",
|
/>
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ fontWeight: 600, fontSize: 14 }}>
|
|
||||||
{t("connections.components.actions.filter")}
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={handleClearFilters}
|
|
||||||
disabled={!hasActiveFilters}
|
|
||||||
>
|
|
||||||
{t("connections.components.actions.clearFilters")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Stack spacing={1.5}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
size="small"
|
|
||||||
options={filterOptions.host}
|
|
||||||
value={filters.host}
|
|
||||||
onChange={handleFilterChange("host")}
|
|
||||||
filterSelectedOptions
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={t("connections.components.fields.host")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
size="small"
|
|
||||||
options={filterOptions.sourceIP}
|
|
||||||
value={filters.sourceIP}
|
|
||||||
onChange={handleFilterChange("sourceIP")}
|
|
||||||
filterSelectedOptions
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={t("connections.components.fields.sourceIP")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
size="small"
|
|
||||||
options={filterOptions.destinationIP}
|
|
||||||
value={filters.destinationIP}
|
|
||||||
onChange={handleFilterChange("destinationIP")}
|
|
||||||
filterSelectedOptions
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={t("connections.components.fields.destinationIP")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
size="small"
|
|
||||||
options={filterOptions.network}
|
|
||||||
value={filters.network}
|
|
||||||
onChange={handleFilterChange("network")}
|
|
||||||
filterSelectedOptions
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={t("connections.components.fields.network")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
size="small"
|
|
||||||
options={filterOptions.sourcePort}
|
|
||||||
value={filters.sourcePort}
|
|
||||||
onChange={handleFilterChange("sourcePort")}
|
|
||||||
filterSelectedOptions
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={t("connections.components.fields.sourcePort")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
size="small"
|
|
||||||
options={filterOptions.destinationPort}
|
|
||||||
value={filters.destinationPort}
|
|
||||||
onChange={handleFilterChange("destinationPort")}
|
|
||||||
filterSelectedOptions
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label={t("connections.components.fields.destinationPort")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Popover>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
"& > *": {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BaseSearchBox onSearch={handleSearch} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user