diff --git a/locales/en/common.json b/locales/en/common.json index 78fccfecd..220d97c00 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -15,6 +15,8 @@ "CLEAR_FILTERS": "Clear all filters", "CLEAR_RECENT": "Clear recent", "CLOSE": "Close", + "COPIED": "Copied", + "COPY": "Copy", "CREATE": "Create", "CREATING": "Creating", "CRITICAL": "CRITICAL", diff --git a/locales/en/public.json b/locales/en/public.json index 78fb4fae9..9c9b9eff3 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -12,6 +12,25 @@ "OPEN_SOURCE_LICENSE": "Open source license", "VERSION": "Version" }, + "AgentLiveProbes": { + "SEARCH_PLACEHOLDER": "Find by name, description, class, or method..." + }, + "AgentLiveProves": { + "ARIA_LABELS": { + "SEARCH_INPUT": "active-probe-filter" + } + }, + "AgentProbeTemplates": { + "SEARCH_PLACEHOLDER": "Find by name or XML content..." + }, + "AllArchivedRecordingsTable": { + "SEARCH_PLACEHOLDER": "Find by JVM ID..." + }, + "AllTargetsArchivedRecordingsTable": { + "HIDE_TARGET_WITH_ZERO_RECORDING": "Hide targets with zero Recordings", + "SEARCH_PLACEHOLDER": "Find by connection URL or alias...", + "TARGET_DISPLAY": "{{alias}} ({{connectUrl}})" + }, "AppLayout": { "APP_LAUNCHER": { "ABOUT": "About", @@ -164,6 +183,9 @@ "CreateRule": { "ABOUT": "Automated Rules are configurations that instruct Cryostat to create JDK Flight Recordings on matching Target JVM applications. Each Automated Rule specifies parameters for which Event Template to use, how much data should be kept in the application Recording buffer, and how frequently Cryostat should copy the application Recording buffer into Cryostat's own archived storage.", "ARCHIVAL_PERIOD_HELPER_TEXT": "Time between copies of active Recording data being pulled into Cryostat archive storage.", + "ARIA_LABELS": { + "HELPER_ICON": "More info for Match Expression field" + }, "DESCRIPTION_HELPER_TEXT": "Enter a rule description. This is only used for display purposes to aid in identifying rules and their intentions.", "ENABLE_SWITCH_HELPER_TEXT": "Rules take effect when created if enabled and will be matched against all discovered target applications immediately. When new target applications appear they are checked against all enabled rules. Disabled rules have no effect but are available to be enabled in the future.", "EVALUATING_EXPRESSION": "Evaluating Match Expression...", @@ -274,6 +296,12 @@ "ErrorView": { "EVENT_TEMPLATES": "Error retrieving Event Templates" }, + "EventTemplates": { + "SEARCH_PLACEHOLDER": "Find by name, description, or provider..." + }, + "EventTypes": { + "SEARCH_PLACEHOLDER": "Find by name, description, typeId, or description..." + }, "JvmDetailsCard": { "CARD_DESCRIPTION": "Display details about the selected Target JVM.", "CARD_DESCRIPTION_FULL": "View information such as the connection URL, Labels, and Annotations belonging to the selected Target JVM.", @@ -325,19 +353,23 @@ "INVALID_UPLOADS_one": "The file does not contain valid Recording metadata:", "INVALID_UPLOADS_other": "These files do not contain valid Recording metadata:" }, + "RecordingsTable": { + "NO_ARCHIVES": "No Archived Recordings" + }, "RuleDeleteWarningModal": { "CLEAN_DESCRIPTION": "Clean will stop any Active Recordings that {{ruleName}} created." }, "Rules": { - "ABOUT_BODY": "Automated Rules define a dynamic set of target JVMs to connect to and start <0>Active Recordings using a specific <1>Event Template when the Automated Rule is created and when any new matching target JVMs appear. If your Target JVM connections require JMX Credentials, you can configure these in <2>Security. Automated Rules can be configured to periodically copy the contents of the Active Recording to <3>Archives to ensure you always have up-to-date information about your JVMs.", - "ABOUT_TITLE": "About Automated Rules", + "ABOUT_BODY": "Automated Rules are configurations that instruct Cryostat to create JDK Flight Recordings on matching target JVM applications, using a specific <1>Event Template. If your Target JVM connections require JMX Credentials, you can configure these in <2>Security. Automated Rules can be configured to periodically copy the contents of the Active Recording to <3>Archives to ensure you always have up-to-date information about your JVMs.", "ARCHIVAL_PERIOD_TOOLTIP": "Period in seconds. Cryostat will connect to matching targets at this interval and copy the relevant Recording data into its archives. Values less than 1 prevent data from being repeatedly copied into archives - Recordings will be started and remain only in Target JVM memory.", "EVENT_SPECIFIER_TOOLTIP": "The name and location of the Event Template applied by this rule.", "INITIAL_DELAY_TOOLTIP": "Initial delay in seconds. Cryostat will wait this amount of time before first copying Recording data into its archives. Values less than 0 default to equal to the Archival period. You can set a non-zero Initial delay with a zero Archival period, which will start a Recording and copy it into archives exactly once after a set delay.", "MATCH_EXPRESSION_TOOLTIP": "A code-snippet expression which must evaluate to a boolean when applied to a given target. If the expression evaluates to true then the rule applies to that target.", "MAX_AGE_TOOLTIP": "The maximum age in seconds for data kept in the JFR Recordings started by this rule. Values less than 1 indicate no limit.", "MAX_SIZE_TOOLTIP": "The maximum size in bytes for JFR Recordings started by this rule. Values less than 1 indicate no limit.", - "PRESERVED_ARCHIVES_TOOLTIP": "The number of Recording copies to be maintained in the Cryostat archives. Cryostat will continue retrieving further archived copies and trimming the oldest copies from the archive to maintain this limit. Values less than 1 prevent data from being copied into archives - Recordings will be started and remain only in Target JVM memory." + "NO_RULES": "No Automated Rules", + "PRESERVED_ARCHIVES_TOOLTIP": "The number of Recording copies to be maintained in the Cryostat archives. Cryostat will continue retrieving further archived copies and trimming the oldest copies from the archive to maintain this limit. Values less than 1 prevent data from being copied into archives - Recordings will be started and remain only in Target JVM memory.", + "SEARCH_PLACEHOLDER": "Find by name, or description..." }, "RulesUploadModal": { "DESCRIPTION": "Select an Automated Rules definition file to upload. File must be in valid JSON format.", @@ -386,7 +418,7 @@ }, "DESCRIPTION": "", "LOCALE_SELECT_DESCRIPTION": "Select current date locale.", - "SEARCH_PLACEHOLDER": "Filter by locale...", + "SEARCH_PLACEHOLDER": "Find by locale...", "TIMEZONE_SELECT_DESCRIPTION": "Select current timezone.", "TITLE": "Date & Time" }, @@ -441,7 +473,7 @@ "CLEAR_SELECTION": "Clear selection", "CREATE_TARGET": "Create Target", "NO_SEARCH_MATCHES": "No Target found", - "SEARCH_PLACEHOLDER": "Filter by URL, alias, or discovery group...", + "SEARCH_PLACEHOLDER": "Find by URL, alias, or discovery group...", "TOGGLE_LABEL": "Select Target", "TOGGLE_PLACEHOLDER": "Select a Target" }, @@ -476,7 +508,7 @@ "SELECT": "Select a timezone", "TYPE_AHEAD": "Search a timezone" }, - "SEARCH_PLACEHOLDER": "Filter by timezone..." + "SEARCH_PLACEHOLDER": "Find by timezone..." }, "Topology": { "GRAPH_VIEW": "Graph view", diff --git a/src/app/Agent/AgentLiveProbes.tsx b/src/app/Agent/AgentLiveProbes.tsx index b10a46980..ce5c20be8 100644 --- a/src/app/Agent/AgentLiveProbes.tsx +++ b/src/app/Agent/AgentLiveProbes.tsx @@ -29,12 +29,12 @@ import { ToolbarContent, ToolbarGroup, ToolbarItem, - TextInput, Stack, StackItem, EmptyState, EmptyStateIcon, EmptyStateHeader, + SearchInput, } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; import { @@ -49,7 +49,9 @@ import { Tr, Td, } from '@patternfly/react-table'; +import _ from 'lodash'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { combineLatest } from 'rxjs'; import { AboutAgentCard } from './AboutAgentCard'; @@ -85,8 +87,9 @@ const tableColumns: TableColumn[] = [ export interface AgentLiveProbesProps {} -export const AgentLiveProbes: React.FC = (_) => { +export const AgentLiveProbes: React.FC = () => { const context = React.useContext(ServiceContext); + const { t } = useTranslation(); const addSubscription = useSubscriptions(); const [probes, setProbes] = React.useState([]); @@ -245,14 +248,14 @@ export const AgentLiveProbes: React.FC = (_) => { if (!filterText) { filtered = probes; } else { - const ft = filterText.trim().toLowerCase(); + const reg = new RegExp(_.escapeRegExp(filterText), 'i'); filtered = probes.filter( (t: EventProbe) => - t.name.toLowerCase().includes(ft) || - t.description.toLowerCase().includes(ft) || - t.clazz.toLowerCase().includes(ft) || - t.methodDescriptor.toLowerCase().includes(ft) || - t.methodName.toLowerCase().includes(ft), + reg.test(t.name) || + reg.test(t.description) || + reg.test(t.clazz) || + reg.test(t.methodDescriptor) || + reg.test(t.methodName), ); } @@ -325,16 +328,18 @@ export const AgentLiveProbes: React.FC = (_) => { - + ); @@ -306,47 +325,48 @@ export const AllArchivedRecordingsTable: React.FC - - } - headingLevel="h4" - /> - + + + } + headingLevel="h4" + /> + + ); } else { view = ( - <> - - - - - ))} - - - {rowPairs} -
- {tableColumns.map(({ title, width }, index) => ( - ['width']} - > - {title} -
- + + + + + ))} + + + {rowPairs} +
+ {tableColumns.map(({ title, width }, index) => ( + ['width']} + > + {title} +
); } return ( - <> + - {view} - + {view} + ); }; diff --git a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx index c276298bd..e548ae03f 100644 --- a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx +++ b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx @@ -29,15 +29,31 @@ import { ToolbarGroup, ToolbarItem, SearchInput, - Badge, Checkbox, EmptyState, EmptyStateIcon, EmptyStateHeader, + Button, + Icon, + Bullseye, } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; -import { Table, Th, Thead, Tbody, Tr, Td, ExpandableRowContent, SortByDirection } from '@patternfly/react-table'; +import { FileIcon, SearchIcon } from '@patternfly/react-icons'; +import { + Table, + Th, + Thead, + Tbody, + Tr, + Td, + ExpandableRowContent, + SortByDirection, + OuterScrollContainer, + InnerScrollContainer, +} from '@patternfly/react-table'; +import { TFunction } from 'i18next'; +import _ from 'lodash'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -45,9 +61,11 @@ const tableColumns: TableColumn[] = [ { title: 'Target', keyPaths: ['target'], - transform: (target: Target, _obj: ArchivesForTarget) => { + transform: (target: Target, _obj: ArchivesForTarget, t?: TFunction) => { return target.alias === target.connectUrl || !target.alias ? `${target.connectUrl}` + : t + ? t('AllTargetsArchivedRecordingsTable.TARGET_DISPLAY', { alias: target.alias, connectUrl: target.connectUrl }) : `${target.alias} (${target.connectUrl})`; }, sortable: true, @@ -82,6 +100,8 @@ export interface AllTargetsArchivedRecordingsTableProps {} export const AllTargetsArchivedRecordingsTable: React.FC = () => { const context = React.useContext(ServiceContext); + const { t } = useTranslation(); + const [searchText, setSearchText] = React.useState(''); const [archivesForTargets, setArchivesForTargets] = React.useState([]); const [expandedTargets, setExpandedTargets] = React.useState([] as Target[]); @@ -315,21 +335,27 @@ export const AllTargetsArchivedRecordingsTable: React.FC { + const _transform = tableColumns[0].transform; + if (_transform) { + return `${_transform(target, undefined, t)}`; + } + // should not occur + return `${target.connectUrl}`; + }, + [t], + ); + React.useEffect(() => { refreshArchivesForTargets(); }, [refreshArchivesForTargets]); const searchedArchivesForTargets = React.useMemo(() => { - let updated: ArchivesForTarget[]; - if (!searchText) { - updated = archivesForTargets; - } else { - const formattedSearchText = searchText.trim().toLowerCase(); - updated = archivesForTargets.filter( - ({ target: t }) => - t.alias.toLowerCase().includes(formattedSearchText) || - t.connectUrl.toLowerCase().includes(formattedSearchText), - ); + let updated: ArchivesForTarget[] = archivesForTargets; + if (searchText) { + const reg = new RegExp(_.escapeRegExp(searchText), 'i'); + updated = archivesForTargets.filter(({ target }) => reg.test(targetDisplay(target))); } return sortResources( { @@ -339,7 +365,7 @@ export const AllTargetsArchivedRecordingsTable: React.FC !hideEmptyTargets || v.archiveCount > 0), tableColumns, ); - }, [searchText, archivesForTargets, sortBy, hideEmptyTargets]); + }, [searchText, archivesForTargets, sortBy, hideEmptyTargets, targetDisplay]); React.useEffect(() => { addSubscription( @@ -415,17 +441,20 @@ export const AllTargetsArchivedRecordingsTable: React.FC - {target.alias == target.connectUrl || !target.alias - ? `${target.connectUrl}` - : `${target.alias} (${target.connectUrl})`} + {targetDisplay(target)} - {archiveCount} + ); }); - }, [toggleExpanded, searchedArchivesForTargets, expandedTargets]); + }, [toggleExpanded, searchedArchivesForTargets, expandedTargets, targetDisplay]); const recordingRows = React.useMemo(() => { return searchedArchivesForTargets.map(({ target, targetAsObs }) => { @@ -477,68 +506,67 @@ export const AllTargetsArchivedRecordingsTable: React.FC - - } - headingLevel="h4" - /> - + + + } + headingLevel="h4" + /> + + ); } else { view = ( - <> - - - - - ))} - - - {rowPairs} -
- {tableColumns.map(({ title, width }, idx) => ( - ['width']} - sort={getSortParams(idx)} - > - {title} -
- + + + + + ))} + + + {rowPairs} +
+ {tableColumns.map(({ title, width }, idx) => ( + ['width']} + sort={getSortParams(idx)} + > + {title} +
); } return ( - <> + - - - - - + + + - {view} - + {view} + ); }; diff --git a/src/app/Archives/Archives.tsx b/src/app/Archives/Archives.tsx index de30b50ca..992301c47 100644 --- a/src/app/Archives/Archives.tsx +++ b/src/app/Archives/Archives.tsx @@ -110,9 +110,10 @@ export const Archives: React.FC = ({ ...props }) => { return ( - - {cardBody} + + {cardBody} + <> ); }; diff --git a/src/app/BreadcrumbPage/BreadcrumbPage.tsx b/src/app/BreadcrumbPage/BreadcrumbPage.tsx index 70f18a8db..6f0d2430a 100644 --- a/src/app/BreadcrumbPage/BreadcrumbPage.tsx +++ b/src/app/BreadcrumbPage/BreadcrumbPage.tsx @@ -36,7 +36,7 @@ interface BreadcrumbPageProps { export const BreadcrumbPage: React.FC = ({ pageTitle, breadcrumbs, children }) => { return ( - + {(breadcrumbs || []).map(({ title, path }) => ( diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index 64d31473d..537145b01 100644 --- a/src/app/Events/EventTemplates.tsx +++ b/src/app/Events/EventTemplates.tsx @@ -35,12 +35,12 @@ import { FormGroup, Modal, ModalVariant, - TextInput, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, EmptyStateHeader, + SearchInput, } from '@patternfly/react-core'; import { SearchIcon, UploadIcon } from '@patternfly/react-icons'; import { @@ -57,7 +57,9 @@ import { ThProps, Tr, } from '@patternfly/react-table'; +import _ from 'lodash'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { forkJoin, Observable, of } from 'rxjs'; import { catchError, concatMap, defaultIfEmpty, filter, first, tap } from 'rxjs/operators'; @@ -87,9 +89,10 @@ const tableColumns: TableColumn[] = [ export interface EventTemplatesProps {} -export const EventTemplates: React.FC = (_) => { +export const EventTemplates: React.FC = () => { const context = React.useContext(ServiceContext); const navigate = useNavigate(); + const { t } = useTranslation(); const [templates, setTemplates] = React.useState([]); const [filteredTemplates, setFilteredTemplates] = React.useState([]); @@ -121,12 +124,9 @@ export const EventTemplates: React.FC = (_) => { if (!filterText) { filtered = templates; } else { - const ft = filterText.trim().toLowerCase(); + const reg = new RegExp(_.escapeRegExp(filterText), 'i'); filtered = templates.filter( - (t: EventTemplate) => - t.name.toLowerCase().includes(ft) || - t.description.toLowerCase().includes(ft) || - t.provider.toLowerCase().includes(ft), + (t: EventTemplate) => reg.test(t.name) || reg.test(t.description) || reg.test(t.provider), ); } @@ -351,11 +351,12 @@ export const EventTemplates: React.FC = (_) => { - setFilterText(value)} value={filterText} @@ -363,6 +364,7 @@ export const EventTemplates: React.FC = (_) => { /> + - - - - - {ruleToWarn ? ( - - ) : null} - - - {viewContent} - + + + {t('AUTOMATED_RULES', { ns: 'common' })} + + + , + , + , + , + ]} + > + Rules.ABOUT_BODY + + + + + {viewContent} + <> diff --git a/src/app/Shared/Components/MatchExpression/MatchExpressionDisplay.tsx b/src/app/Shared/Components/MatchExpression/MatchExpressionDisplay.tsx new file mode 100644 index 000000000..8a3903154 --- /dev/null +++ b/src/app/Shared/Components/MatchExpression/MatchExpressionDisplay.tsx @@ -0,0 +1,39 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ClipboardCopy } from '@patternfly/react-core'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface MatchExpressionDisplayProps { + matchExpression: string; +} + +export const MatchExpressionDisplay: React.FC = ({ matchExpression }) => { + const { t } = useTranslation(); + return ( + + {matchExpression} + + ); +}; diff --git a/src/app/app.css b/src/app/app.css index 9c75156ba..0787db205 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -78,14 +78,34 @@ body, --pf-v5-c-chip__text--MaxWidth: 100ch; } +.rules-table-outer-container { + height: 67.5vh; +} + +.rules-table-inner-container { + height: 100%; +} + .recording-table-outer-container { - height: 67.3vh; + height: 64.5vh; } .recording-table--inner-container { height: 100%; } +.archive-table-outer-container { + height: 75vh; +} + +.archive-table-inner-container { + height: 100%; +} + +.archive-table-outer-container .pf-v5-c-table__expandable-row { + z-index: 0; +} + #dashboard-layout-dropdown-toggle { width: 20em; } @@ -736,3 +756,7 @@ svg.topology__node-decorator-icon.progress { .theme__toggle-group .pf-v5-c-toggle-group__button.pf-m-selected { --pf-v5-c-toggle-group__button--m-selected--BackgroundColor: var(--pf-v5-global--palette--blue-500); } + +.match-expression-display { + padding: .25rem .5rem !important; +} diff --git a/src/app/utils/hooks/useSort.ts b/src/app/utils/hooks/useSort.ts index a0ccc3fd5..d13969e5e 100644 --- a/src/app/utils/hooks/useSort.ts +++ b/src/app/utils/hooks/useSort.ts @@ -17,7 +17,7 @@ import { ISortBy, ThProps } from '@patternfly/react-table'; import * as React from 'react'; export const useSort = (): [ISortBy, (columnIndex: number) => ThProps['sort']] => { - const [sortBy, setSortBy] = React.useState({} as ISortBy); + const [sortBy, setSortBy] = React.useState({}); const getSortParams = React.useCallback( (columnIndex: number): ThProps['sort'] => ({ sortBy: sortBy, diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 27f0646d3..dd3febacb 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -17,6 +17,7 @@ import { KeyValue } from '@app/Shared/Services/api.types'; import { ISortBy, SortByDirection } from '@patternfly/react-table'; import humanizeDuration from 'humanize-duration'; +import { TFunction } from 'i18next'; import _ from 'lodash'; import { NavigateFunction } from 'react-router-dom'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -185,7 +186,7 @@ export interface TableColumn { title: string; tooltip?: string; keyPaths?: string[]; - transform?: (value: unknown, _rec: unknown) => unknown; + transform?: (value: unknown, _rec: unknown, t?: TFunction) => unknown; sortable?: boolean; width?: number; } @@ -267,6 +268,3 @@ export const isAssetNew = (currVer: string) => { const oldVer: string = getFromLocalStorage('ASSET_VERSION', '0.0.0'); return !semverValid(oldVer) || semverGt(currVer, oldVer); }; - -export const includesSubstr = (a: string, b: string): boolean => - !!a && !!b && a.toLowerCase().includes(b.trim().toLowerCase());