From f7b9a72131ecc8e018d5119c2856dba362eddf73 Mon Sep 17 00:00:00 2001 From: TheDiveO <6920158+thediveo@users.noreply.github.com> Date: Sun, 11 Feb 2024 21:09:55 +0100 Subject: [PATCH] Feature/filter (#43) * wip: filter input component for containees list Signed-off-by: thediveo * wip: add proper filter clear button Signed-off-by: thediveo * wip: poc filtering the wiring view Signed-off-by: thediveo * wip: filter containees list in sidebar Signed-off-by: thediveo * wip: show filter only for breadboard view; show error state for input field for invalid regexps Signed-off-by: thediveo * wip: hotkey, input focus, close on enter Signed-off-by: thediveo * wip: use hotkey ^f instead of ^s; disable spell checkar on regexp, sherly! Signed-off-by: thediveo * wip: support details filtering; display notice when filtering is being applied Signed-off-by: thediveo * doc: update integrated help Signed-off-by: thediveo --------- Signed-off-by: thediveo --- webui/icons/Case.svg | 64 +++++++ webui/icons/Regexp.svg | 64 +++++++ webui/package.json | 2 + webui/src/app/App.tsx | 54 ++++-- .../components/appbardrawer/AppBarDrawer.tsx | 30 +++- .../containeenavigator/ContaineeNavigator.tsx | 22 ++- .../components/filterinput/FilterInput.tsx | 168 ++++++++++++++++++ webui/src/components/filterinput/index.tsx | 1 + .../stripednotice/StripedNotice.tsx | 51 ++++++ webui/src/components/stripednotice/index.tsx | 1 + webui/src/icons/Case.tsx | 5 + webui/src/icons/Regexp.tsx | 5 + webui/src/views/everything/Everything.tsx | 128 ++++++++----- webui/src/views/help/Help.tsx | 5 +- webui/src/views/help/chapters/Drawer.mdx | 62 ++++++- webui/src/views/help/chapters/Ghostwire.mdx | 4 +- webui/src/views/netnswiring/NetnsWiring.tsx | 35 +++- webui/src/views/settings/Settings.tsx | 9 + webui/yarn.lock | 11 ++ 19 files changed, 649 insertions(+), 72 deletions(-) create mode 100644 webui/icons/Case.svg create mode 100644 webui/icons/Regexp.svg create mode 100644 webui/src/components/filterinput/FilterInput.tsx create mode 100644 webui/src/components/filterinput/index.tsx create mode 100644 webui/src/components/stripednotice/StripedNotice.tsx create mode 100644 webui/src/components/stripednotice/index.tsx create mode 100644 webui/src/icons/Case.tsx create mode 100644 webui/src/icons/Regexp.tsx diff --git a/webui/icons/Case.svg b/webui/icons/Case.svg new file mode 100644 index 0000000..22a72f4 --- /dev/null +++ b/webui/icons/Case.svg @@ -0,0 +1,64 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/webui/icons/Regexp.svg b/webui/icons/Regexp.svg new file mode 100644 index 0000000..ddc16a3 --- /dev/null +++ b/webui/icons/Regexp.svg @@ -0,0 +1,64 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/webui/package.json b/webui/package.json index c0eec1a..5abd05c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -42,6 +42,7 @@ "process": "^0.11.10", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^v5.0.0-1", "react-inlinesvg": "^3.0.2", "react-router-dom": "^6.21.2", "react-scripts": "^5.0.1", @@ -70,6 +71,7 @@ }, "scripts": { "start": "REACT_APP_GIT_VERSION=${GIT_VERSION:-$(git describe)} REACT_APP_ENABLE_MONOLITH=true vite --port 3300", + "unstrict": "REACT_APP_GIT_VERSION=${GIT_VERSION:-$(git describe)} REACT_APP_UNSTRICT=true REACT_APP_ENABLE_MONOLITH=true vite --port 3300", "build": "REACT_APP_GIT_VERSION=${GIT_VERSION:-$(git describe)} vite build", "imagebuild": "REACT_APP_GIT_VERSION=${GIT_VERSION:-$(git describe)} vite build", "icons": "node genicons", diff --git a/webui/src/app/App.tsx b/webui/src/app/App.tsx index 4ce35a7..bbc1623 100644 --- a/webui/src/app/App.tsx +++ b/webui/src/app/App.tsx @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import React, { useRef } from 'react' -import { BrowserRouter as Router, Route, Routes, useMatch, Navigate} from 'react-router-dom' +import { BrowserRouter as Router, Route, Routes, useMatch, Navigate } from 'react-router-dom' import { basename } from 'utils/basename' @@ -25,6 +25,7 @@ import { Tooltip, Typography, useMediaQuery, + Grid, } from '@mui/material'; import { gwDarkTheme, gwLightTheme } from './appstyles' @@ -43,7 +44,7 @@ import OpenHouseIcon from 'icons/views/OpenHouse' import { About as AboutView } from 'views/about' import { Help as HelpView } from 'views/help' -import { Settings as SettingsView, showEmptyNetnsAtom, themeAtom, THEME_DARK, THEME_USERPREF } from 'views/settings' +import { Settings as SettingsView, showEmptyNetnsAtom, themeAtom, THEME_DARK, THEME_USERPREF, filterPatternAtom, filterCaseSensitiveAtom, filterRegexpAtom } from 'views/settings' import { Everything as EverythingView } from 'views/everything' import { NetnsWiring } from 'views/netnswiring' import { NetnsDetails as NetnsDetailsView } from 'views/netnsdetails' @@ -57,6 +58,7 @@ import { BrandIcon } from 'components/brandicon' import { useDynVars } from 'components/dynvars' import { ScreenShooter, useScreenShooterModal } from 'components/screenshooter' import OpenHouse from 'views/openhouse/OpenHouse' +import { FilterInput, FilterPattern } from 'components/filterinput' const SettingsViewIcon = SettingsIcon @@ -72,9 +74,9 @@ const refresherIntervals = [ ] /** - * The `LxGhostwireApp` component renders the general app layout without - * thinking about providers for routing, themes, discovery, et cetera. So this - * component deals with: + * The `GhostwireApp` component renders the general app layout without thinking + * about providers for routing, themes, discovery, et cetera. So this component + * deals with: * - app bar with title, number of namespaces badge, quick actions. * - drawer for navigating the different views and types of namespaces. * - scrollable content area. @@ -82,6 +84,9 @@ const refresherIntervals = [ const GhostwireApp = () => { const [showEmptyNetns] = useAtom(showEmptyNetnsAtom) + const [filterPattern, setFilterPattern] = useAtom(filterPatternAtom) + const [filterCase, setFilterCase] = useAtom(filterCaseSensitiveAtom) + const [filterRegexp, setFilterRegexp] = useAtom(filterRegexpAtom) // Determine the number of discovered network namespaces, as well as the // number of shown network namespaces: depending on filter settings and @@ -106,7 +111,9 @@ const GhostwireApp = () => { const nmatch1 = useMatch('/n') const nmatch2 = useMatch('/n/:slug') const isDetails = nmatch1 !== null || nmatch2 != null - + + const canFilter = wmatch1 !== null || nmatch1 !== null + const listContainees = isWiring || isDetails const alsoSnapshotable = useMatch('/lochla') !== null @@ -115,10 +122,16 @@ const GhostwireApp = () => { const enableSnapshot = listContainees || alsoSnapshotable // What to capture, if any. - const snapshotRef = useRef(null) + const snapshotRef = useRef(null) const setModal = useScreenShooterModal() + const onFilterChangeHandler = (fp: FilterPattern) => { + if (filterPattern != fp.pattern) setFilterPattern(fp.pattern) + if (filterCase != fp.isCaseSensitive) setFilterCase(fp.isCaseSensitive) + if (filterRegexp != fp.isRegexp) setFilterRegexp(fp.isRegexp) + } + useScrollToHash(scrollIdIntoView) return ( @@ -150,7 +163,7 @@ const GhostwireApp = () => { } - drawer={closeDrawer => <> + drawer={(closeDrawer, focusRef) => <> { {listContainees && <> Containees} + subheader={ { + event.stopPropagation() + event.preventDefault() + }}> + + Containees + { canFilter && + + + + } + + } onClick={closeDrawer} > { {/* main content area */} - } /> + } /> } /> } /> } /> diff --git a/webui/src/components/appbardrawer/AppBarDrawer.tsx b/webui/src/components/appbardrawer/AppBarDrawer.tsx index affdef4..c149499 100644 --- a/webui/src/components/appbardrawer/AppBarDrawer.tsx +++ b/webui/src/components/appbardrawer/AppBarDrawer.tsx @@ -2,12 +2,13 @@ // // SPDX-License-Identifier: MIT -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import MenuIcon from '@mui/icons-material/Menu' import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' import ChevronRightIcon from '@mui/icons-material/ChevronRight' import { AppBar, Box, Divider, IconButton, styled, SwipeableDrawer, Theme, Toolbar, useTheme } from '@mui/material' +import { useHotkeys } from 'react-hotkeys-hook' // Width of drawer. @@ -74,7 +75,7 @@ export interface AppBarDrawerProps { * want to close the drawer whenever the user clicks on them in order to * navigate to a different route. */ - drawer?: (drawerCloser: drawerCloser) => React.ReactNode + drawer?: (drawerCloser: drawerCloser, focusRef?: React.RefObject) => React.ReactNode /** * optionally sets the width in pixels of the drawer. Defaults to 240 pixels * if unspecified. @@ -124,6 +125,29 @@ const AppBarDrawer = ({ // Not much state here in ... Denmark?! const [drawerOpen, setDrawerOpen] = useState(false) + // We need to get hold onto the filter pattern input field in order to + // autofocus upon opening the drawer. Below, we pass it to the drawer + // rendering function, so it can pass it down even further into whatever + // HTML it want to set the focus on. + const focusRef = useRef(null) + + // Register hotkey to open the drawer and put the focus on the containee + // filter pattern input, if any. + useHotkeys(['/', 'ctrl+f'], (e) => { + e.preventDefault() + e.stopPropagation() + setDrawerOpen(true) + }, { useKey: true }) + + // When the drawer opens, and if there is an HTML element reference then set + // the focus to this HTML element. This will be the containee filter pattern + // input field, where enabled depending on view. + useEffect(() => { + if (!drawerOpen) return + if (!focusRef.current) return + focusRef.current.focus() + }, [drawerOpen]) + // Convenience handlers for dealing with the swipeable drawer, that should // keep users busy on a rainy Sunday afternoon. const openDrawer = () => { setDrawerOpen(true) } @@ -169,7 +193,7 @@ const AppBarDrawer = ({ - {drawer && drawer(closeDrawer)} + {drawer && drawer(closeDrawer, focusRef)} } diff --git a/webui/src/components/containeenavigator/ContaineeNavigator.tsx b/webui/src/components/containeenavigator/ContaineeNavigator.tsx index 9f727cd..33c6f3a 100644 --- a/webui/src/components/containeenavigator/ContaineeNavigator.tsx +++ b/webui/src/components/containeenavigator/ContaineeNavigator.tsx @@ -13,6 +13,9 @@ import { ContaineeIcon } from 'utils/containeeicon' import { useContextualId } from 'components/idcontext' import PrivilegedIcon from 'icons/containeestates/Privileged' import CapableIcon from 'icons/containeestates/Capable' +import { useAtom } from 'jotai' +import { filterCaseSensitiveAtom, filterPatternAtom, filterRegexpAtom } from 'views/settings' +import { getFilterFn } from 'components/filterinput' const ContaineeItem = styled(ListItemButton)(({ theme }) => ({ @@ -176,6 +179,7 @@ export interface ContaineeNavigatorProps { * navigator will not show any containees of such empty network namespaces. */ export const ContaineeNavigator = ({ allnetns, filterEmpty, nolink }: ContaineeNavigatorProps) => { + const domIdBase = useContextualId('') const match1 = useMatch('/:view') @@ -184,14 +188,26 @@ export const ContaineeNavigator = ({ allnetns, filterEmpty, nolink }: ContaineeN const inDetails = !!match && !!(match.params as { [key: string]: string })['details'] + const [filterPattern] = useAtom(filterPatternAtom) + const [filterCase] = useAtom(filterCaseSensitiveAtom) + const [filterRegexp] = useAtom(filterRegexpAtom) + const filterfn = getFilterFn({ + pattern: filterPattern, + isCaseSensitive: filterCase, + isRegexp: filterRegexp, + }) + // Get all pods and those pesky primitive containees that aren't pot'ed. In // case a containee should appear multiple times, it is attached to multiple // network namespaces and we're fine with that. const allContainees = Object.values(allnetns) .filter(netns => !emptyNetns(netns) || !filterEmpty) - .map(netns => - (netns.pods as Containee[]).concat( - netns.containers.filter(primcontainee => !isPodContainer(primcontainee)))) + .map(netns => { + const primcontainees = netns.containers.filter(primcntee => filterfn(primcntee.name)) + const pods = netns.pods.filter(pod => filterfn(pod.name)) + return (pods as Containee[]).concat( + primcontainees.filter(primcontainee => !isPodContainer(primcontainee))) + }) .flat() // bang all namespace-local containees into a single flat list. // Prepare the item list from the containees where this list now replaces diff --git a/webui/src/components/filterinput/FilterInput.tsx b/webui/src/components/filterinput/FilterInput.tsx new file mode 100644 index 0000000..426745c --- /dev/null +++ b/webui/src/components/filterinput/FilterInput.tsx @@ -0,0 +1,168 @@ +// (c) Siemens AG 2024 +// +// SPDX-License-Identifier: MIT + +import React, { useEffect, useMemo, useState } from 'react' + +import { Box, IconButton, TextField, ToggleButton, ToggleButtonGroup, debounce } from '@mui/material' +import CaseIcon from 'icons/Case' +import RegexpIcon from 'icons/Regexp' +import { Clear } from '@mui/icons-material' + +export interface FilterPattern { + pattern: string + isCaseSensitive: boolean + isRegexp: boolean +} + +export const getFilterFn = (fp: FilterPattern) => { + if (!fp.isRegexp) { + if (fp.isCaseSensitive) { + return (s: string) => s.includes(fp.pattern) + } + const filter = fp.pattern.toLocaleLowerCase() + return (s: string) => s.toLocaleLowerCase().includes(filter) + } + try { + const re = new RegExp(fp.pattern, fp.isCaseSensitive ? "" : "i") + return (s: string) => re.test(s) + } catch (e) { + return () => false + } +} + +export interface FilterInputProps { + /** + * an optional filter pattern, including the matching options, such as + * case-sensitive and interpretation of the pattern as a regexp instead of a + * verbatim substring. + */ + filterPattern?: FilterPattern + /** + * callback that is called whenever the filter pattern or its options + * changes; this callback already is debounced. + * + * @param fp filter patting including options. + * @returns void + */ + onChange: (fp: FilterPattern) => void + /** + * callback when the Enter key is pressed. + * + * @returns void + */ + onEnter?: () => void + /** pattern change callback debounce wait in milliseconds. */ + debounceWait?: number + /** + * an optional React reference (object) that will be set to the text input's + * HTML input field. + */ + focusRef?: React.RefObject +} + +export const FilterInput = ({ filterPattern, onChange, debounceWait, focusRef, onEnter }: FilterInputProps) => { + + debounceWait = debounceWait || 300 + + const [pattern, setPattern] = useState('') + const [filterOptions, setFilterOptions] = useState([]) + + useEffect(() => { + setPattern(filterPattern?.pattern || '') + setFilterOptions((filterPattern?.isCaseSensitive ? ["case"] : []).concat(filterPattern?.isRegexp ? ["regexp"] : [])) + }, [filterPattern]) + + const onChangeHandler = (pattern: string, options: string[]) => { + if (!onChange) { + return + } + const isCaseSensitive = options.includes('case') + const isRegexp = options.includes('regexp') + const fp = { + pattern: pattern, + isCaseSensitive: isCaseSensitive, + isRegexp: isRegexp, + } as FilterPattern + onChange(fp) + } + + const debouncedOnChange = useMemo( + () => debounce(onChangeHandler, debounceWait), + [onChange]) + + const handleInput = (event: React.ChangeEvent) => { + const newPattern = event.target.value + setPattern(newPattern) + debouncedOnChange(newPattern, filterOptions) + } + + const handleClear = () => { + const newPattern = '' + setPattern(newPattern) + debouncedOnChange(newPattern, filterOptions) + } + + const handleOptions = (event: React.MouseEvent, newopts: string[]) => { + setFilterOptions(newopts) + debouncedOnChange(pattern, newopts) + } + + const handleEnter = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' || !onEnter) { + return + } + onEnter() + } + + // If the pattern is to be used as a regular expression, do a dry run in + // order to determine whether the regexp pattern is valid or not. We later + // use this to control the text input field's error indication. + let regexpError = false + if (filterOptions.includes('regexp')) { + try { + new RegExp(pattern) + } catch (e) { + regexpError = true + } + } + + return + + + + }} + /> + + + + + + + + + +} + +export default FilterInput diff --git a/webui/src/components/filterinput/index.tsx b/webui/src/components/filterinput/index.tsx new file mode 100644 index 0000000..aa8d305 --- /dev/null +++ b/webui/src/components/filterinput/index.tsx @@ -0,0 +1 @@ +export * from './FilterInput' diff --git a/webui/src/components/stripednotice/StripedNotice.tsx b/webui/src/components/stripednotice/StripedNotice.tsx new file mode 100644 index 0000000..87d6d05 --- /dev/null +++ b/webui/src/components/stripednotice/StripedNotice.tsx @@ -0,0 +1,51 @@ +// (c) Siemens AG 2024 +// +// SPDX-License-Identifier: MIT + +import React, { ReactNode } from 'react' +import { styled } from '@mui/material' +import { rgba } from 'utils/rgba' + +const Container = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), +})) + +const Message = styled('div')(({ theme }) => ({ + color: rgba(theme.palette.text.secondary, 0.5), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), +})) + +const Stripe = styled('div')(({ theme }) => ({ + flex: 1, + height: '1rem', + background: `repeating-linear-gradient(` + + `-55deg, ` + + `${rgba(theme.palette.text.secondary, 0.3)}, ` + + `${rgba(theme.palette.text.secondary, 0.3)} 10px, ` + + `${theme.palette.background.default} 10px, ` + + `${theme.palette.background.default} 20px)`, +})) + +export interface StripedNoticeProps { + /** mandatory children elements */ + children: ReactNode +} + +/** + * `StripedNotice` renders a horizontal "hazzard stripe"-type bar, with a + * centered message. + */ +export const StripedNotice = ({ children }: StripedNoticeProps) => { + return + + {children} + + +} + +export default StripedNotice diff --git a/webui/src/components/stripednotice/index.tsx b/webui/src/components/stripednotice/index.tsx new file mode 100644 index 0000000..a0feb00 --- /dev/null +++ b/webui/src/components/stripednotice/index.tsx @@ -0,0 +1 @@ +export * from './StripedNotice' diff --git a/webui/src/icons/Case.tsx b/webui/src/icons/Case.tsx new file mode 100644 index 0000000..353dab4 --- /dev/null +++ b/webui/src/icons/Case.tsx @@ -0,0 +1,5 @@ +// autogenerated from icon svg file "icons/Case.svg", do not edit; +import * as React from 'react'; +import { SvgIcon, SvgIconProps } from '@mui/material'; +export const CaseIcon = (props: SvgIconProps) => ; +export default CaseIcon; \ No newline at end of file diff --git a/webui/src/icons/Regexp.tsx b/webui/src/icons/Regexp.tsx new file mode 100644 index 0000000..1326e48 --- /dev/null +++ b/webui/src/icons/Regexp.tsx @@ -0,0 +1,5 @@ +// autogenerated from icon svg file "icons/Regexp.svg", do not edit; +import * as React from 'react'; +import { SvgIcon, SvgIconProps } from '@mui/material'; +export const RegexpIcon = (props: SvgIconProps) => ; +export default RegexpIcon; \ No newline at end of file diff --git a/webui/src/views/everything/Everything.tsx b/webui/src/views/everything/Everything.tsx index d60841c..e89d8ef 100644 --- a/webui/src/views/everything/Everything.tsx +++ b/webui/src/views/everything/Everything.tsx @@ -10,19 +10,22 @@ import { useAtom } from 'jotai' import { Box, styled, Typography } from '@mui/material' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' +import FilterAltIcon from '@mui/icons-material/FilterAlt' import { CardTray } from 'components/cardtray' import { NetnsDetailCard } from 'components/netnsdetailcard' import { useDiscovery } from 'components/discovery' import { emptyNetns, isProject, orderNetnsByContainees, sortedNetnsProjects } from 'models/gw' -import { showEmptyNetnsAtom, showIpFamiliesAtom, showLoopbackAtom, showMACAtom } from 'views/settings' +import { filterCaseSensitiveAtom, filterPatternAtom, filterRegexpAtom, showEmptyNetnsAtom, showIpFamiliesAtom, showLoopbackAtom, showMACAtom } from 'views/settings' import { Ghost } from 'components/ghost' import RefreshButton from 'components/refreshbutton' import { createMuiShadow } from 'utils/shadow' import ProjectCard from 'components/projectcard/ProjectCard' import Metadata from 'components/metadata' +import { getFilterFn } from 'components/filterinput' +import { StripedNotice } from 'components/stripednotice' const MarkableNetnsDetailCard = styled(NetnsDetailCard)(({ theme }) => ({ @@ -40,69 +43,98 @@ const MarkableNetnsDetailCard = styled(NetnsDetailCard)(({ theme }) => ({ * Renders a full, detailed view of all discovered network namespaces. */ export const Everything = React.forwardRef>((props, ref) => { + const [showLoopbacks] = useAtom(showLoopbackAtom) const [showEmptyNetns] = useAtom(showEmptyNetnsAtom) const [showMAC] = useAtom(showMACAtom) const [families] = useAtom(showIpFamiliesAtom) + const [filterPattern] = useAtom(filterPatternAtom) + const [filterCase] = useAtom(filterCaseSensitiveAtom) + const [filterRegexp] = useAtom(filterRegexpAtom) + const filterfn = getFilterFn({ + pattern: filterPattern, + isCaseSensitive: filterCase, + isRegexp: filterRegexp, + }) + // Did the user navigate to a specific network namespace...? const location = useLocation() const locmatch = location && location.hash.match(/^#.*-netns-(\d+)$/) const netnsid = (locmatch && parseInt(locmatch[1])) || 0 const discovery = useDiscovery() - const netnses = Object.values(discovery.networkNamespaces) + const orignetnses = Object.values(discovery.networkNamespaces) .filter(netns => showEmptyNetns || !emptyNetns(netns)) + const netnses = orignetnses + .filter(ns => { + if (ns.containers.find(primcntee => filterfn(primcntee.name))) { + return true + } + return ns.pods.find(pod => filterfn(pod.name)) + }) .sort(orderNetnsByContainees) - const netnsesAndProjs = sortedNetnsProjects(netnses) - return (netnses.length !== 0 && + return ( -
- - - {netnsesAndProjs.map(netnsOrProj => { - if (isProject(netnsOrProj)) { - return - {Object.values(netnsOrProj.netnses) - .sort(orderNetnsByContainees) - .map(netns => - ) - } - - } else { - return - } - })} - -
-
) - || ( - -   - nothing discovered yet, please refresh - - ) + {(netnses.length && +
+ + {!!filterPattern && + +   + filtering applied + } + + {netnsesAndProjs.map(netnsOrProj => { + if (isProject(netnsOrProj)) { + return + {Object.values(netnsOrProj.netnses) + .filter(netns => netns.containers.find(primcntee => filterfn(primcntee.name))) + .sort(orderNetnsByContainees) + .map(netns => + ) + } + + } else { + return + } + })} + +
) + || (orignetnses.length && + +   + no matches, please check the filter settings in the sidebar. + ) + || ( + +   + nothing discovered yet, please refresh + + )} +
+ ) }) Everything.displayName = "Everything" diff --git a/webui/src/views/help/Help.tsx b/webui/src/views/help/Help.tsx index be807cf..8932b26 100644 --- a/webui/src/views/help/Help.tsx +++ b/webui/src/views/help/Help.tsx @@ -14,7 +14,7 @@ import { HelpViewer, HelpViewerChapter } from 'components/helpviewer' import { GwMarkdown } from 'components/gwmarkdown' import { MuiMarkdownProps } from 'components/muimarkdown' -import { containeesCutoffAtom, nifsCutoffAtom, portsCutoffAtom, routesCutoffAtom, showEmptyNetnsAtom, showIpv4Atom, showIpv6Atom, showLoopbackAtom, showMACAtom, showNamespaceIdsAtom, showSandboxesAtom } from 'views/settings' +import { containeesCutoffAtom, filterCaseSensitiveAtom, filterPatternAtom, filterRegexpAtom, nifsCutoffAtom, portsCutoffAtom, routesCutoffAtom, showEmptyNetnsAtom, showIpv4Atom, showIpv6Atom, showLoopbackAtom, showMACAtom, showNamespaceIdsAtom, showSandboxesAtom } from 'views/settings' import { useHydrateAtoms } from 'jotai/utils' @@ -134,6 +134,9 @@ export const Help = () => { [nifsCutoffAtom, 100], [showSandboxesAtom, false], [showNamespaceIdsAtom, true], + [filterPatternAtom, ""], + [filterCaseSensitiveAtom, false], + [filterRegexpAtom, false] ]}> { + const [filterPattern, setFilterPattern] = useAtom(filterPatternAtom) + const [filterCase, setFilterCase] = useAtom(filterCaseSensitiveAtom) + const [filterRegexp, setFilterRegexp] = useAtom(filterRegexpAtom) + + const onFilterChangeHandler = (fp) => { + if (filterPattern != fp.pattern) setFilterPattern(fp.pattern) + if (filterCase != fp.isCaseSensitive) setFilterCase(fp.isCaseSensitive) + if (filterRegexp != fp.isRegexp) setFilterRegexp(fp.isRegexp) + } + + return { + event.stopPropagation() + event.preventDefault() + }}> + + Containees + + + + + } + > + + +} # Navigation Drawer @@ -57,16 +97,32 @@ swiping from the left side of the application. Only when the  wiring and  details views the navigation drawer will additionally show the list of available pods, containers, and stand-alone -processes – the so-called ["containees"](containees). For instance: +processes – the so-called "[containees](containees)". For instance:

- +

+##### Filter + +The list – as well as the wiring and details views – can be filtered to show +only elements matching a filter pattern. The pattern is interpreted to be either +a substring or an ECMAScript/JavaScript [regular +expression](https://regex101.com/). If you like, you can try in our above +example: it is live! + +- switches case sensitivity on or off. + +- switches between substring match and + regular expression match. + +> **Note:** press "/" or Ctrl+f to open the drawer and move your text cursor +> directly into the filter input field. + ##### Pods ./. Containers The so-called "pods" group containers with the same virtual IP stack, the diff --git a/webui/src/views/help/chapters/Ghostwire.mdx b/webui/src/views/help/chapters/Ghostwire.mdx index 875e085..a71efa6 100644 --- a/webui/src/views/help/chapters/Ghostwire.mdx +++ b/webui/src/views/help/chapters/Ghostwire.mdx @@ -24,7 +24,9 @@ network configuration inside your Linux host (including - listening and connected TCP and UDP ports, - TCP and UDP port forwarding including the serving containers and processes; - and this not only for the host itself, but also in all containers. + and this not only for the host itself, but also in all containers, + +- filter the displayed IP stacks by container names, et cetera, - easily create screenshots of the wiring for documentation and trouble shooting, using the screenshot button in diff --git a/webui/src/views/netnswiring/NetnsWiring.tsx b/webui/src/views/netnswiring/NetnsWiring.tsx index c861c82..7a30284 100644 --- a/webui/src/views/netnswiring/NetnsWiring.tsx +++ b/webui/src/views/netnswiring/NetnsWiring.tsx @@ -6,16 +6,19 @@ import React from 'react' import { useAtom } from 'jotai' +import FilterAltIcon from '@mui/icons-material/FilterAlt' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import { Box, Typography } from '@mui/material' import { useDiscovery } from 'components/discovery' import { orderNetnsByContainees } from 'models/gw' import { NetnsBreadboard } from 'components/netnsbreadboard' -import { showEmptyNetnsAtom, showIpFamiliesAtom, showLoopbackAtom } from 'views/settings' +import { filterCaseSensitiveAtom, filterPatternAtom, filterRegexpAtom, showEmptyNetnsAtom, showIpFamiliesAtom, showLoopbackAtom } from 'views/settings' import { Ghost } from 'components/ghost' import RefreshButton from 'components/refreshbutton' import Metadata from 'components/metadata' +import { getFilterFn } from 'components/filterinput' +import { StripedNotice } from 'components/stripednotice' /** @@ -30,15 +33,36 @@ export const NetnsWiring = React.forwardRef { + if (ns.containers.find(primcntee => filterfn(primcntee.name))) { + return true + } + return ns.pods.find(pod => filterfn(pod.name)) + }) .sort(orderNetnsByContainees) return ( - {(netnses.length !== 0 && + {(netnses.length &&

+ {!!filterPattern && + +   + filtering applied + }
) + || (orignetnses.length && + +   + no matches, please check the filter settings in the sidebar. + ) || (   diff --git a/webui/src/views/settings/Settings.tsx b/webui/src/views/settings/Settings.tsx index 94470c3..bb0112a 100644 --- a/webui/src/views/settings/Settings.tsx +++ b/webui/src/views/settings/Settings.tsx @@ -51,6 +51,11 @@ const showIEAppIconsKey = 'ghostwire.showieappicons' const showMultiBroadcastRoutesKey = 'ghostwire.showmultibroadcastroutes' +const filterPatternKey = 'ghostwire.filter.pattern' +const filterCaseSensitiveKey = 'ghostwire.filter.case' +const filterRegexpKey = 'ghostwire.filter.regexp' + + export const THEME_USERPREF = 0 export const THEME_LIGHT = 1 export const THEME_DARK = -1 @@ -87,6 +92,10 @@ export const showIEAppIconsAtom = atomWithStorage(showIEAppIconsKey, false) export const showMultiBroadcastRoutesAtom = atomWithStorage(showMultiBroadcastRoutesKey, false) +export const filterPatternAtom = atomWithStorage(filterPatternKey, '') +export const filterCaseSensitiveAtom = atomWithStorage(filterCaseSensitiveKey, false) +export const filterRegexpAtom = atomWithStorage(filterRegexpKey, false) + const cutOffEm = 12 const SettingsGrid = styled(Grid)(({ theme }) => ({ diff --git a/webui/yarn.lock b/webui/yarn.lock index df52b70..c4b9aae 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -10665,6 +10665,7 @@ __metadata: process: "npm:^0.11.10" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" + react-hotkeys-hook: "npm:v5.0.0-1" react-inlinesvg: "npm:^3.0.2" react-router: "npm:^6.21.2" react-router-dom: "npm:^6.21.2" @@ -16542,6 +16543,16 @@ __metadata: languageName: node linkType: hard +"react-hotkeys-hook@npm:v5.0.0-1": + version: 5.0.0-1 + resolution: "react-hotkeys-hook@npm:5.0.0-1" + peerDependencies: + react: ">=16.8.1" + react-dom: ">=16.8.1" + checksum: c29716a6408e668efe9ab8deecae6556464777b1a0dad8fc3c6519011223fa845cee50b90117a55b9ecff61509fec9822fba122d7cd5517f90764bbe68134076 + languageName: node + linkType: hard + "react-inlinesvg@npm:^3.0.2": version: 3.0.3 resolution: "react-inlinesvg@npm:3.0.3"